diff --git a/.coveragerc b/.coveragerc index 4335cec..c9a0649 100644 --- a/.coveragerc +++ b/.coveragerc @@ -13,3 +13,4 @@ exclude_lines = [run] omit = tests/* + benchmarks/* diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index dc430a9..5d889a7 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -69,6 +69,12 @@ jobs: python -m pip install --upgrade pip pip install -e ".[${{ matrix.extras }}]" + - name: Verify ASV benchmark suite (Python 3.12, bench extra) + if: matrix.python-version == '3.12' && matrix.extras != 'test' + run: | + pip install -e ".[bench]" + cd benchmarks && asv check --python=same + - name: Verify Numba installation (fast extra) if: contains(matrix.extras, 'fast') run: | diff --git a/.github/workflows/sphinx-docs-preview.yml b/.github/workflows/sphinx-docs-preview.yml index 54a272d..9807f61 100644 --- a/.github/workflows/sphinx-docs-preview.yml +++ b/.github/workflows/sphinx-docs-preview.yml @@ -32,7 +32,7 @@ jobs: pip install -e ".[docs]" - name: Build HTML - run: sphinx-build -b html docs docs/_build/html + run: python -m sphinx.cmd.build -b html docs docs/_build/html - name: Upload HTML artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/sphinx-to-github-pages.yml b/.github/workflows/sphinx-to-github-pages.yml index 26df461..e44de7e 100644 --- a/.github/workflows/sphinx-to-github-pages.yml +++ b/.github/workflows/sphinx-to-github-pages.yml @@ -1,8 +1,7 @@ -# https://github.com/marketplace/actions/sphinx-to-github-pages +# Publish Sphinx docs and ASV benchmark HTML to GitHub Pages. name: Deploy Sphinx documentation to Pages -# Production deploy only when docs/source land on the default branch(es). on: push: branches: [main, master] @@ -17,9 +16,40 @@ jobs: pages: write id-token: write steps: - - id: deployment - uses: sphinx-notes/pages@v3 - with: - # Doc deps live only in pyproject.toml [project.optional-dependencies.docs]. - requirements_path: "" - pyproject_extras: docs + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + + - name: Install documentation and benchmark dependencies + run: | + python -m pip install --upgrade pip + pip install .[docs,fast,bench] + + - name: Run ASV and publish benchmark site into Sphinx extra path + run: | + mkdir -p docs/_benchmark_site/benchmarks + cd benchmarks + asv machine --yes + asv run master --show-stderr + asv publish -o ../docs/_benchmark_site/benchmarks + + - name: Build HTML documentation + run: python -m sphinx.cmd.build -b html docs /tmp/sphinx-html + + - uses: actions/configure-pages@v6 + + - name: Fix file permissions for Pages upload + run: | + chmod -c -R +rX /tmp/sphinx-html || true + + - uses: actions/upload-pages-artifact@v5.0.0 + with: + path: /tmp/sphinx-html + + - id: deployment + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index 5231148..e897259 100644 --- a/.gitignore +++ b/.gitignore @@ -228,6 +228,12 @@ __marimo__/ # sphinx _autosummary/ +# airspeed velocity (see benchmarks/asv.conf.json) +benchmarks/env/ +benchmarks/results/ +benchmarks/html/ +docs/_benchmark_site/ + # Build-time copy of examples into docs (see conf.py prepare_examples_doc) docs/examples/README.md docs/examples/results/ diff --git a/benchmarks/asv.conf.json b/benchmarks/asv.conf.json new file mode 100644 index 0000000..c31a2f0 --- /dev/null +++ b/benchmarks/asv.conf.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "project": "elsim", + "project_url": "https://endolith.github.io/elsim/", + "repo": "..", + "branches": ["master"], + "show_commit_url": "https://github.com/endolith/elsim/commit/", + "pythons": ["3.12"], + "environment_type": "virtualenv", + "install_command": [ + "in-dir={build_dir} python -m pip install -e \".[fast]\"" + ], + "benchmark_dir": "benchmarks", + "env_dir": "env", + "results_dir": "results", + "html_dir": "html", + "default_benchmark_timeout": 300, + "build_cache_size": 8 +} diff --git a/benchmarks/benchmarks/__init__.py b/benchmarks/benchmarks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/benchmarks/benchmarks/elections.py b/benchmarks/benchmarks/elections.py new file mode 100644 index 0000000..0dc71e1 --- /dev/null +++ b/benchmarks/benchmarks/elections.py @@ -0,0 +1,38 @@ +"""ASV timings for ``elsim.elections``.""" + +import numpy as np + +from elsim.elections import ( + impartial_culture, + normal_electorate, + normed_dist_utilities, + random_utilities, +) + +N_VOTERS = 800 +N_CANDS = 7 + + +class ElectionsSuite: + """Fixed electorate size; spatial model uses two dimensions by default.""" + + def setup_cache(self): + rng = np.random.default_rng(42) + spatial = normal_electorate(N_VOTERS, N_CANDS, random_state=rng) + return { + "rng": np.random.default_rng(43), + "spatial": spatial, + } + + def time_random_utilities(self, cache): + random_utilities(N_VOTERS, N_CANDS, random_state=cache["rng"]) + + def time_impartial_culture(self, cache): + impartial_culture(N_VOTERS, N_CANDS, random_state=cache["rng"]) + + def time_normal_electorate(self, cache): + normal_electorate(N_VOTERS, N_CANDS, random_state=cache["rng"]) + + def time_normed_dist_utilities(self, cache): + voters, cands = cache["spatial"] + normed_dist_utilities(voters, cands) diff --git a/benchmarks/benchmarks/methods.py b/benchmarks/benchmarks/methods.py new file mode 100644 index 0000000..3c8e8f1 --- /dev/null +++ b/benchmarks/benchmarks/methods.py @@ -0,0 +1,115 @@ +"""ASV timings for ``elsim.methods``.""" + +import numpy as np + +from elsim.elections import impartial_culture, random_utilities +from elsim.methods import ( + approval, + black, + borda, + combined_approval, + condorcet, + condorcet_from_matrix, + coombs, + fptp, + irv, + matrix_from_scores, + ranked_election_to_matrix, + runoff, + score, + sntv, + star, + utility_winner, +) +from elsim.strategies import approval_optimal, honest_normed_scores + +N_VOTERS = 800 +N_CANDS = 7 + + +class RankedBallotMethods: + def setup_cache(self): + election = impartial_culture(N_VOTERS, N_CANDS, random_state=42) + return np.asarray(election) + + def time_black(self, election): + black(election) + + def time_borda(self, election): + borda(election) + + def time_fptp(self, election): + fptp(election) + + def time_sntv(self, election): + sntv(election, n=2) + + def time_runoff(self, election): + runoff(election) + + def time_irv(self, election): + irv(election) + + def time_coombs(self, election): + coombs(election) + + +class ScoredBallotMethods: + def setup_cache(self): + utilities = random_utilities(N_VOTERS, N_CANDS, random_state=42) + election = honest_normed_scores(utilities) + return np.asarray(election) + + def time_score(self, election): + score(election) + + def time_star(self, election): + star(election) + + def time_matrix_from_scores(self, election): + matrix_from_scores(election) + + +class ApprovalBallotMethods: + def setup_cache(self): + utilities = random_utilities(N_VOTERS, N_CANDS, random_state=42) + election = approval_optimal(utilities) + return np.asarray(election) + + def time_approval(self, election): + approval(election) + + +class CombinedApprovalBallots: + def setup_cache(self): + rng = np.random.default_rng(42) + return rng.integers(-1, 2, size=(N_VOTERS, N_CANDS)) + + def time_combined_approval(self, election): + combined_approval(election) + + +class UtilityWinnerBallots: + def setup_cache(self): + return random_utilities(N_VOTERS, N_CANDS, random_state=42) + + def time_utility_winner(self, utilities): + utility_winner(utilities) + + +class CondorcetSuite: + def setup_cache(self): + election = np.asarray( + impartial_culture(N_VOTERS, N_CANDS, random_state=42) + ) + matrix = ranked_election_to_matrix(election) + return {"election": election, "matrix": matrix} + + def time_ranked_election_to_matrix(self, data): + ranked_election_to_matrix(data["election"]) + + def time_condorcet(self, data): + condorcet(data["election"]) + + def time_condorcet_from_matrix(self, data): + condorcet_from_matrix(data["matrix"]) diff --git a/benchmarks/benchmarks/strategies.py b/benchmarks/benchmarks/strategies.py new file mode 100644 index 0000000..df9e609 --- /dev/null +++ b/benchmarks/benchmarks/strategies.py @@ -0,0 +1,29 @@ +"""ASV timings for ``elsim.strategies``.""" + +from elsim.elections import random_utilities +from elsim.strategies import ( + approval_optimal, + honest_normed_scores, + honest_rankings, + vote_for_k, +) + +N_VOTERS = 800 +N_CANDS = 7 + + +class StrategiesSuite: + def setup_cache(self): + return random_utilities(N_VOTERS, N_CANDS, random_state=42) + + def time_honest_rankings(self, utilities): + honest_rankings(utilities) + + def time_honest_normed_scores(self, utilities): + honest_normed_scores(utilities) + + def time_approval_optimal(self, utilities): + approval_optimal(utilities) + + def time_vote_for_k(self, utilities): + vote_for_k(utilities, 3) diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..1d9660c --- /dev/null +++ b/codecov.yml @@ -0,0 +1,16 @@ +# https://docs.codecov.com/docs/codecovyml-reference +coverage: + status: + project: + default: + paths: + - "elsim/" + - "tests/" + patch: + default: + paths: + - "elsim/" + - "tests/" + +ignore: + - "benchmarks/" diff --git a/docs/benchmarks.rst b/docs/benchmarks.rst new file mode 100644 index 0000000..afd83db --- /dev/null +++ b/docs/benchmarks.rst @@ -0,0 +1,14 @@ +Benchmarks +========== + +Performance of the public API is tracked with `Airspeed Velocity (ASV)`_. + +.. _Airspeed Velocity (ASV): https://asv.readthedocs.io/en/stable/ + +An `interactive ASV report`_ is published alongside this documentation when the default branch is deployed (same GitHub Pages site). + +.. _interactive ASV report: ../benchmarks/ + +The CI job runs on Ubuntu with Python 3.12 and installs optional-dependencies ``fast`` so Numba-backed code paths are exercised. Results accrue on that runner; for local comparisons or additional commits, install optional-dependencies ``bench``, ``cd`` into ``benchmarks/``, and follow the `ASV workflow`_ (configuration is in ``asv.conf.json``). + +.. _ASV workflow: https://asv.readthedocs.io/en/stable/using.html diff --git a/docs/conf.py b/docs/conf.py index cf7f97a..eb20065 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -4,6 +4,9 @@ # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html +import os +import shutil + # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, @@ -95,8 +98,13 @@ # Paths in that file (./results/) are then relative to the document and resolve # correctly. Copy repo examples/README.md and examples/results/ into docs/examples/ # at build start so the doc and Sphinx can find them. -import os -import shutil + +# ASV HTML is optional locally; CI writes docs/_benchmark_site/benchmarks before build. +_docs_dir = os.path.dirname(os.path.abspath(__file__)) +_benchmark_site = os.path.join(_docs_dir, '_benchmark_site') +html_extra_path = [] +if os.path.isdir(_benchmark_site): + html_extra_path.append('_benchmark_site') def prepare_examples_doc(app, config): diff --git a/docs/index.rst b/docs/index.rst index e30e566..7639d04 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,6 +6,7 @@ :caption: Contents: examples/index + benchmarks modules diff --git a/pyproject.toml b/pyproject.toml index 235e83d..100c856 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,10 +56,14 @@ docs = [ "sphinxcontrib-apidoc", "linkify-it-py", ] +bench = [ + "asv", +] [project.urls] Homepage = "https://github.com/endolith/elsim" Documentation = "https://endolith.github.io/elsim/" +Benchmarks = "https://endolith.github.io/elsim/benchmarks/" [tool.hatch.version] path = "elsim/__init__.py"