diff --git a/README.md b/README.md index 434dbc2..9939425 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,147 @@ # Smithy -*A simple smith set solver for ranked-choice ballots.* +*A simple Smith set solver for ranked-choice ballots.* + +The Smith set is the minimal set of election candidates which can beat all others pairwise +(by simple majority ranking preference) - if there is a single winner in the set they are +guaranteed the standard Condorcet i.e. Majority winner (they beat all others pairwise). + +`smithy` currently identifies the set by brute-force search which is combinatoric complexity +in the worst case (TODO: better [algorithm](https://en.wikipedia.org/wiki/Floyd%E2%80%93Warshall_algorithm)) +but appears approximately $O(n^2)$ on-average in the number of candidates for typical/random ballots, +and linear in the number of ballots. + + +## Usage +`smithy` can be used a few different ways: as a CLI command, via a web form upload, +or imported as a python package (TODO: a small [GUI](https://doc.qt.io/qtforpython-6/)). + + +### CLI Command +`smithy` provides a [`click`](https://click.palletsprojects.com/en/stable/) +CLI command defined in `src/cmd.py` which can be invoked a couple of ways: + +- The [GitHub Releases Page](https://github.com/tgorordo/smithy/releases) provides standalone +CLI executables bundled using [PyInstaller](https://pyinstaller.org/en/stable/index.html) +for linux and windows on `x86_64` and linux on `aarch64`. Downloading the appropriate +executable should give you access to the `smithycmd` CLI command. + +- Alternatively, if you are using `uv` (see [Development](#Development)), you can invoke +this in your shell as `uv run src/smithycmd.py [...]`. + +In either case, the command expects the same argument structure: +TODO + +While plain output is the default, so that the command can easily be used in a unix-pipe +or `stdio` workflows, it can also pretty-print its output for your reading pleasure +using [`rich`](https://rich.readthedocs.io/en/stable/introduction.html) if you pass it the +`--pretty` (or `-p`) flag. + + +### UOregon CGI Application +[**This** `pages.uoregon.edu` page](https://pages.uoregon.edu/tgorordo/files/smithy/src/cgi/form.html) +hosts a [CGI form](https://service.uoregon.edu/TDClient/2030/Portal/KB/ArticleDet?ID=43069) +which accepts a `.csv` or `.xls`(`x`) upload of a ballot-box and responds with the resulting +Smith set. The application can typically handle ~100s of candidates and ~10ks of votes +before running into hosting resource limitations and timing out (pathological cases, involving +many or large cycles, or especially a large cyclic tie, may perform worse and are not extensively tested). + + +### Python Package +The `smithy` python package primarily provides the function `smith_set(ballot_box_df)` +(defined in `src/smithy/rcv.py`), which returns the smith set given a +[polars](https://docs.pola.rs/api/python/stable/reference/index.html) +dataframe `ballot_box_df` whose columns are candidates and whose rows are RCV ballots. + +e.g. `smithy` can be imported into a [marimo](https://docs.marimo.io/#highlights) notebook +as seen in `test/test_nb.py` - if you're using `uv` you can launch marimo via +`uv run marimo --edit` and navigate to that example notebook +(`just marimo` is an available shorthand if you are using the `just` taskrunner -- +see [Development](#Development)). Cloning `smithy` then working in scratch notebooks within the repo +(e.g. makeing your own `nbs/` directory) +is probably the easiest way to use it if you need to do some of cleanup of the tabular +data before invocation - just do your cleanup in a notebook using +[polars](https://docs.pola.rs/py-polars/html/reference/) then pass a tidy dataframe to `smith_set(df)`. + +Of course, you can also import into any python script or package for a more involved project +as well: +- If you're using `uv` adding `smithy` should be as simple as +```bash +uv add git+https://github.com/tgorordo/smithy.git +``` +- Alternatively, if you're using the more default `pip` you can make it available by +```bash +git clone https://github.com/tgorordo/smithy.git # or submodule add somewhere appropriate in your project +cd smithy +pip install . +``` +(if you're new to pip-venvs, +[this brief guide might be helpful](https://pages.uoregon.edu/tgorordo/courses/uoph410-510a_Image-Analysis/setup.html)). + + +## Development +Current development tooling is based on the [`uv`](https://docs.astral.sh/uv/) Python package and +project manager. A [`justfile`](https://github.com/casey/just) lists some common useful +task commands. A simple [`shell.nix`](https://nix.dev/tutorials/first-steps/declarative-shell.html) +can be used to load these tools into a local environment, and you might find +[`direnv`](https://github.com/direnv/direnv) a convenient pairing to `nix` - but just having a +system-provided `uv` available is more than enough to use and develop `smithy`. + + + +### Formatting, Checking, Testing and Locking +Source can be formatted using [`ruff`](https://docs.astral.sh/ruff/) by running +```bash +uv run ruff format src test +``` +(or `just format`) +which should be done before any `git` commit. + +This is not really taken full advantage of at the moment, but type hints can be checked +by running +```bash +uv run pyright src +``` +(or `just check`). + +A test suite can be run as +```bash +uv run pytest -vvv --tb=short --log-cli-level=INFO +``` +(or `just test`). + +Dependencies can be locked by running +```bash +uv lock +uv pip compile pyproject.toml -o requirements.txt --group dev +``` +(or `just lock`). + + +### Bundling, Releases, Deployment and Cleanup +Bundling with [PyInstaller](https://pyinstaller.org/en/stable/index.html) can be done by +```bash +uv run pyinstaller --clean -F src/cmd.py --name smithycmd +``` +which will output an executable `smithycmd` for the CLI in `dist/`, which can be uploaded +to the latest [GitHub Releases Page](https://github.com/tgorordo/smithy/releases). + +Deploying the main CGI page at +[`pages.uoregon.edu/tgorordo/files/smithy/src/cgi`](https://pages.uoregon.edu/tgorordo/files/smithy/src/cgi) +requires shell or SFTP access to [my `pages.uoregon.edu`](https://pages.uoregon.edu/tgorordo), +which you won't get unless you're [me](https://github.com/tgorordo). + +If you want to deploy it to your own `pages.uoregon.edu` page, then it should be enough to +ensure you have [CGI set up](https://service.uoregon.edu/TDClient/2030/Portal/KB/ArticleDet?ID=43069) +then SFTP into `sftp.uoregon.edu` and `put -r smithy` (or git clone when `ssh`ed into +`shell.uoregon.edu`) wherever you'd like in your `public_html`, ensure `smithy.cgi` has +`chmod 755` permissions, +[standalone install `uv` for your user](https://docs.astral.sh/uv/getting-started/installation/) +then run `uv sync` from somewhere inside the `smithy` directory. Navigating to the `smithy.cgi` + file in `pages.uoregon.edu/YOUR_USER/.../cgi/smithy.cgi` should give you a working form. + +If you're using `just` you can cleanup working files by running `just clean` and can fully +wipe your `uv` generated `.venv` with `just wipe` (if you want manual versions of those, +check the `justfile` definition). It's probably best to do this before any commit as well, +though probably not required if you're careful or have `.gitignore` updated appropriately +for any changes you made. -The Smith set is the minimal set of candidates which can beat all others pairwise - if there is a single winner -in the set they are guaranteed the standard Condorcet i.e. Majority winner (they beat all others pairwise). diff --git a/justfile b/justfile index 8f7a4de..631c88b 100644 --- a/justfile +++ b/justfile @@ -1,8 +1,8 @@ list: just --list --unsorted -run spreadsheet: - uv run smithy {{spreadsheet}} +run *args: + uv run src/smithycmd.py {{args}} marimo: uv run marimo --edit @@ -25,7 +25,7 @@ compile: clean: uv run pyclean src test uv run ruff clean - rm -rf main.spec cli.spec build dist .pytest_cache .hypothesis .benchmarks __marimo__ + rm -rf smithy.spec build dist .pytest_cache .hypothesis .benchmarks __marimo__ wipe: just clean diff --git a/pyproject.toml b/pyproject.toml index 1af11f1..1ed7427 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,13 +8,17 @@ authors = [ ] requires-python = ">=3.13" dependencies = [ - "click>=8.4.0", "polars>=1.40.1", +] + +[project.optional-dependencies] +cli = [ + "click>=8.4.0", "rich>=15.0.0", ] -[project.scripts] -smithy = "smithy:cli" +#[project.scripts] +#smithy = "smithycmd:cli" [build-system] requires = ["uv_build>=0.11.7,<0.12.0"] diff --git a/requirements.txt b/requirements.txt index 40ae829..a22fa60 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,6 @@ charset-normalizer==3.4.7 # via requests click==8.4.0 # via - # smithy (pyproject.toml) # marimo # uvicorn distro==1.9.0 @@ -84,12 +83,8 @@ markdown==3.10.2 # via # marimo # pymdown-extensions -markdown-it-py==4.2.0 - # via rich markupsafe==3.0.3 # via jinja2 -mdurl==0.1.2 - # via markdown-it-py msgspec==0.21.1 # via marimo narwhals==2.21.2 @@ -151,7 +146,6 @@ pygments==2.20.0 # via # marimo # pytest - # rich pyinstaller==6.20.0 # via smithy (pyproject.toml:dev) pyinstaller-hooks-contrib==2026.5 @@ -184,8 +178,6 @@ regex==2026.5.9 # via tiktoken requests==2.34.2 # via tiktoken -rich==15.0.0 - # via smithy (pyproject.toml) rpds-py==0.30.0 # via # jsonschema diff --git a/smithy.spec b/smithy.spec deleted file mode 100644 index c7ab993..0000000 --- a/smithy.spec +++ /dev/null @@ -1,38 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - - -a = Analysis( - ['src/cmd.py'], - pathex=[], - binaries=[], - datas=[], - hiddenimports=[], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - noarchive=False, - optimize=0, -) -pyz = PYZ(a.pure) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.datas, - [], - name='smithy', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=True, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, -) diff --git a/src/cgi/form.html b/src/cgi/form.html index 681876d..b66b756 100644 --- a/src/cgi/form.html +++ b/src/cgi/form.html @@ -43,10 +43,10 @@
This is an upload form for the smithy RCV Ballot counter for identifying the Smith set (majority winners) in small elections, -mainly to help with UO Physics Grad student elections of various flavors. This form uses the UO pages.uoregon.edu CGI Capability, +mainly to help with UO Physics Grad student elections of various flavors. This form uses the UO pages.uoregon.edu CGI Capability, so the implementation of smithy being invoked can be inspected here and you may also inspect the source of this page to verify that this script is called to invoke it - though -you have to trust it to be responding with its own contents faithfully. +you have to trust it to quine itself faithfully.
The Smith set is the minimal set of candidates which can beat all others pairwise (by majority ranking) diff --git a/src/cmd.py b/src/cmd.py deleted file mode 100644 index e86230c..0000000 --- a/src/cmd.py +++ /dev/null @@ -1,4 +0,0 @@ -from smithy import cli - -if __name__ == "__main__": - cli() diff --git a/src/smithy/__init__.py b/src/smithy/__init__.py index 701081d..cd454c7 100644 --- a/src/smithy/__init__.py +++ b/src/smithy/__init__.py @@ -1,77 +1 @@ -import click -import polars as pl - -from rich.console import Console -from rich.table import Table -from rich.panel import Panel - from .rcv import smith_set - - -@click.command() -@click.argument("spreadsheet", type=click.Path(exists=True, dir_okay=False)) -def cli(spreadsheet: str) -> None: - """ - Compute the Smith set from a ranked-choice ballot spreadsheet. - - The Smith set is the minimal set of candidates which can beat all others pairwise - if there is a single winner - in the set they are guaranteed the Condorcet i.e. Majority winner. - """ - - console = Console() - - try: - # Load spreadsheet - if spreadsheet.endswith(".csv"): - df = pl.read_csv(spreadsheet) - - elif spreadsheet.endswith((".xlsx", ".xls")): - df = pl.read_excel(spreadsheet) - - else: - console.print( - "[bold red]Unsupported file type.[/bold red]\nUse CSV or Excel." - ) - raise SystemExit(1) - - # Normalize numerical dataframe entries - df = df.with_columns( - [ - pl.col(c) - .cast(pl.Utf8) - .str.strip_chars() - .cast(pl.Int64, strict=False) - .fill_null(0) - for c in df.columns - ] - ) - - # Compute Smith set - smiths = smith_set(df) - - # Preview table - preview = Table(title="Ballot Box") - - for col in df.columns: - preview.add_column(col) - - for row in df.head(5).iter_rows(): - preview.add_row(*map(str, row)) - - console.print(preview) - - # Results - console.print() - - console.print( - Panel.fit( - "\n".join(f"• {c}" for c in smiths), - title="Resulting Smith Set", - border_style="green", - ) - ) - - except Exception as e: - console.print(f"[bold red]Error:[/bold red] {e}") - - raise SystemExit(1) diff --git a/src/smithy/rcv.py b/src/smithy/rcv.py index 482b4d8..2480784 100644 --- a/src/smithy/rcv.py +++ b/src/smithy/rcv.py @@ -1,7 +1,6 @@ import polars as pl from itertools import combinations - def smith_set(df: pl.DataFrame) -> list: """ Compute the Smith set from a Ranked-Choice ballot. diff --git a/src/smithycmd.py b/src/smithycmd.py new file mode 100644 index 0000000..1c1420c --- /dev/null +++ b/src/smithycmd.py @@ -0,0 +1,86 @@ +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "click>=8.4.1", +# "rich>=15.0.0", +# "polars>=1.40.1" +# ] +# /// +import click + +from rich.console import Console +from rich.table import Table +from rich.panel import Panel + +from smithy import smith_set + +@click.command() +@click.argument("spreadsheet", type=click.Path(exists=True, dir_okay=False)) +def cli(spreadsheet: str) -> None: + """ + Compute the Smith set from a ranked-choice ballot spreadsheet. + + The Smith set is the minimal set of candidates which can beat all others pairwise - if there is a single winner + in the set they are guaranteed the Condorcet i.e. Majority winner. + """ + + console = Console() + + try: + # Load spreadsheet + if spreadsheet.endswith(".csv"): + df = pl.read_csv(spreadsheet) + + elif spreadsheet.endswith((".xlsx", ".xls")): + df = pl.read_excel(spreadsheet) + + else: + console.print( + "[bold red]Unsupported file type.[/bold red]\nUse CSV or Excel." + ) + raise SystemExit(1) + + # Normalize numerical dataframe entries + df = df.with_columns( + [ + pl.col(c) + .cast(pl.Utf8) + .str.strip_chars() + .cast(pl.Int64, strict=False) + .fill_null(0) + for c in df.columns + ] + ) + + # Compute Smith set + smiths = smith_set(df) + + # Preview table + preview = Table(title="Ballot Box") + + for col in df.columns: + preview.add_column(col) + + for row in df.head(5).iter_rows(): + preview.add_row(*map(str, row)) + + console.print(preview) + + # Results + console.print() + + console.print( + Panel.fit( + "\n".join(f"• {c}" for c in smiths), + title="Resulting Smith Set", + border_style="green", + ) + ) + + except Exception as e: + console.print(f"[bold red]Error:[/bold red] {e}") + + raise SystemExit(1) + +if __name__ == "__main__": + cli() diff --git a/uv.lock b/uv.lock index 5368060..f8defb4 100644 --- a/uv.lock +++ b/uv.lock @@ -1484,8 +1484,12 @@ name = "smithy" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "click" }, { name = "polars" }, +] + +[package.optional-dependencies] +cli = [ + { name = "click" }, { name = "rich" }, ] @@ -1505,10 +1509,11 @@ dev = [ [package.metadata] requires-dist = [ - { name = "click", specifier = ">=8.4.0" }, + { name = "click", marker = "extra == 'cli'", specifier = ">=8.4.0" }, { name = "polars", specifier = ">=1.40.1" }, - { name = "rich", specifier = ">=15.0.0" }, + { name = "rich", marker = "extra == 'cli'", specifier = ">=15.0.0" }, ] +provides-extras = ["cli"] [package.metadata.requires-dev] dev = [