mirror of
https://github.com/tgorordo/carousel.git
synced 2026-06-05 18:12:14 -07:00
init commit. modeled on tgorordo/smithy, still lots TODO
This commit is contained in:
commit
8bc048c0ee
14 changed files with 2659 additions and 0 deletions
11
README.md
Normal file
11
README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Carousel
|
||||||
|
*A simple Stable Matching solver.*
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
Here are some usage examples:
|
||||||
|
|
||||||
|
### University of Oregon Physics Department Graduate TA Assignments
|
||||||
|
TODO
|
||||||
|
|
||||||
|
### Caltech Housing Rotation
|
||||||
|
TODO
|
||||||
39
justfile
Normal file
39
justfile
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
list:
|
||||||
|
just --list
|
||||||
|
|
||||||
|
run *args:
|
||||||
|
uv run src/carouselcmd.py {{args}}
|
||||||
|
|
||||||
|
python *arguments:
|
||||||
|
uv run python -c {{arguments}}
|
||||||
|
|
||||||
|
marimo:
|
||||||
|
uv run marimo --edit
|
||||||
|
|
||||||
|
sync *args:
|
||||||
|
uv sync {{args}}
|
||||||
|
|
||||||
|
format:
|
||||||
|
uv run ruff format src test
|
||||||
|
|
||||||
|
check:
|
||||||
|
uv run pyright src
|
||||||
|
|
||||||
|
test:
|
||||||
|
uv run pytest -vvv --tb=short --log-cli-level=INFO
|
||||||
|
|
||||||
|
compile:
|
||||||
|
uv run --with-requirements src/carouselcmd.py pyinstaller --clean -F src/carouselcmd.py
|
||||||
|
|
||||||
|
clean:
|
||||||
|
uv run pyclean src test
|
||||||
|
uv run ruff clean
|
||||||
|
rm -rf carouselcmd.spec carouselgui.spec build dist .pytest_cache .hypothesis .benchmarks __marimo__
|
||||||
|
|
||||||
|
wipe:
|
||||||
|
just clean
|
||||||
|
rm -rf .venv
|
||||||
|
|
||||||
|
lock:
|
||||||
|
uv lock
|
||||||
|
uv pip compile pyproject.toml -o requirements.txt --group dev
|
||||||
41
pyproject.toml
Normal file
41
pyproject.toml
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
[project]
|
||||||
|
name = "carousel"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Stable Marriage Solver."
|
||||||
|
readme = "README.md"
|
||||||
|
authors = [{ name = "Thomas (Tom) C. Gorordo", email = "tcgorordo@gmail.com" }]
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"numpy>=2.2.4",
|
||||||
|
"polars>=1.26.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
#[project.scripts]
|
||||||
|
#carousel = "carousel:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["uv_build>=0.11.7,<0.12.0"]
|
||||||
|
build-backend = "uv_build"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"faker>=37.1.0",
|
||||||
|
"hypothesis>=6.130.8",
|
||||||
|
"marimo[recommended]>=0.13.6",
|
||||||
|
"pyclean>=3.1.0",
|
||||||
|
"pyinstaller>=6.12.0",
|
||||||
|
"pyright>=1.1.398",
|
||||||
|
"pytest>=8.3.5",
|
||||||
|
"pytest-benchmark>=5.1.0",
|
||||||
|
"ruff>=0.11.2",
|
||||||
|
"ty>=0.0.0a5",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
[pytest]
|
||||||
|
testpaths = "test"
|
||||||
|
log_cli = true
|
||||||
|
|
||||||
|
[tool.pyright]
|
||||||
|
include = ["src"]
|
||||||
|
exclude = ["test"]
|
||||||
191
requirements.txt
Normal file
191
requirements.txt
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
# This file was autogenerated by uv via the following command:
|
||||||
|
# uv pip compile pyproject.toml -o requirements.txt --group dev
|
||||||
|
altair==5.5.0
|
||||||
|
# via marimo
|
||||||
|
altgraph==0.17.4
|
||||||
|
# via pyinstaller
|
||||||
|
annotated-types==0.7.0
|
||||||
|
# via pydantic
|
||||||
|
anyio==4.10.0
|
||||||
|
# via
|
||||||
|
# httpx
|
||||||
|
# openai
|
||||||
|
# starlette
|
||||||
|
attrs==25.3.0
|
||||||
|
# via
|
||||||
|
# hypothesis
|
||||||
|
# jsonschema
|
||||||
|
# referencing
|
||||||
|
certifi==2025.8.3
|
||||||
|
# via
|
||||||
|
# httpcore
|
||||||
|
# httpx
|
||||||
|
click==8.1.8
|
||||||
|
# via
|
||||||
|
# marimo
|
||||||
|
# uvicorn
|
||||||
|
distro==1.9.0
|
||||||
|
# via openai
|
||||||
|
docutils==0.22
|
||||||
|
# via marimo
|
||||||
|
duckdb==1.3.2
|
||||||
|
# via marimo
|
||||||
|
faker==37.6.0
|
||||||
|
# via carousel (pyproject.toml:dev)
|
||||||
|
fastjsonschema==2.21.2
|
||||||
|
# via nbformat
|
||||||
|
h11==0.16.0
|
||||||
|
# via
|
||||||
|
# httpcore
|
||||||
|
# uvicorn
|
||||||
|
httpcore==1.0.9
|
||||||
|
# via httpx
|
||||||
|
httpx==0.28.1
|
||||||
|
# via openai
|
||||||
|
hypothesis==6.131.5
|
||||||
|
# via carousel (pyproject.toml:dev)
|
||||||
|
idna==3.10
|
||||||
|
# via
|
||||||
|
# anyio
|
||||||
|
# httpx
|
||||||
|
iniconfig==2.1.0
|
||||||
|
# via pytest
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
# via marimo
|
||||||
|
jedi==0.19.2
|
||||||
|
# via marimo
|
||||||
|
jinja2==3.1.6
|
||||||
|
# via altair
|
||||||
|
jiter==0.10.0
|
||||||
|
# via openai
|
||||||
|
jsonschema==4.25.1
|
||||||
|
# via
|
||||||
|
# altair
|
||||||
|
# nbformat
|
||||||
|
jsonschema-specifications==2025.4.1
|
||||||
|
# via jsonschema
|
||||||
|
jupyter-core==5.8.1
|
||||||
|
# via nbformat
|
||||||
|
loro==1.6.0
|
||||||
|
# via marimo
|
||||||
|
marimo==0.15.2
|
||||||
|
# via carousel (pyproject.toml:dev)
|
||||||
|
markdown==3.9
|
||||||
|
# via
|
||||||
|
# marimo
|
||||||
|
# pymdown-extensions
|
||||||
|
markupsafe==3.0.2
|
||||||
|
# via jinja2
|
||||||
|
narwhals==2.3.0
|
||||||
|
# via
|
||||||
|
# altair
|
||||||
|
# marimo
|
||||||
|
nbformat==5.10.4
|
||||||
|
# via marimo
|
||||||
|
nodeenv==1.9.1
|
||||||
|
# via pyright
|
||||||
|
numpy==2.2.4
|
||||||
|
# via carousel (pyproject.toml)
|
||||||
|
openai==1.106.1
|
||||||
|
# via marimo
|
||||||
|
packaging==24.2
|
||||||
|
# via
|
||||||
|
# altair
|
||||||
|
# marimo
|
||||||
|
# pyinstaller
|
||||||
|
# pyinstaller-hooks-contrib
|
||||||
|
# pytest
|
||||||
|
parso==0.8.5
|
||||||
|
# via jedi
|
||||||
|
platformdirs==4.4.0
|
||||||
|
# via jupyter-core
|
||||||
|
pluggy==1.5.0
|
||||||
|
# via pytest
|
||||||
|
polars==1.27.1
|
||||||
|
# via
|
||||||
|
# carousel (pyproject.toml)
|
||||||
|
# marimo
|
||||||
|
psutil==7.0.0
|
||||||
|
# via marimo
|
||||||
|
py-cpuinfo==9.0.0
|
||||||
|
# via pytest-benchmark
|
||||||
|
pyarrow==21.0.0
|
||||||
|
# via polars
|
||||||
|
pyclean==3.1.0
|
||||||
|
# via carousel (pyproject.toml:dev)
|
||||||
|
pydantic==2.11.7
|
||||||
|
# via openai
|
||||||
|
pydantic-core==2.33.2
|
||||||
|
# via pydantic
|
||||||
|
pygments==2.19.1
|
||||||
|
# via marimo
|
||||||
|
pyinstaller==6.13.0
|
||||||
|
# via carousel (pyproject.toml:dev)
|
||||||
|
pyinstaller-hooks-contrib==2025.3
|
||||||
|
# via pyinstaller
|
||||||
|
pymdown-extensions==10.16.1
|
||||||
|
# via marimo
|
||||||
|
pyright==1.1.399
|
||||||
|
# via carousel (pyproject.toml:dev)
|
||||||
|
pytest==8.3.5
|
||||||
|
# via
|
||||||
|
# carousel (pyproject.toml:dev)
|
||||||
|
# pytest-benchmark
|
||||||
|
pytest-benchmark==5.1.0
|
||||||
|
# via carousel (pyproject.toml:dev)
|
||||||
|
pyyaml==6.0.2
|
||||||
|
# via
|
||||||
|
# marimo
|
||||||
|
# pymdown-extensions
|
||||||
|
referencing==0.36.2
|
||||||
|
# via
|
||||||
|
# jsonschema
|
||||||
|
# jsonschema-specifications
|
||||||
|
rpds-py==0.27.1
|
||||||
|
# via
|
||||||
|
# jsonschema
|
||||||
|
# referencing
|
||||||
|
ruff==0.11.6
|
||||||
|
# via
|
||||||
|
# carousel (pyproject.toml:dev)
|
||||||
|
# marimo
|
||||||
|
setuptools==78.1.0
|
||||||
|
# via
|
||||||
|
# pyinstaller
|
||||||
|
# pyinstaller-hooks-contrib
|
||||||
|
sniffio==1.3.1
|
||||||
|
# via
|
||||||
|
# anyio
|
||||||
|
# openai
|
||||||
|
sortedcontainers==2.4.0
|
||||||
|
# via hypothesis
|
||||||
|
sqlglot==27.12.0
|
||||||
|
# via marimo
|
||||||
|
starlette==0.47.3
|
||||||
|
# via marimo
|
||||||
|
tomlkit==0.13.3
|
||||||
|
# via marimo
|
||||||
|
tqdm==4.67.1
|
||||||
|
# via openai
|
||||||
|
traitlets==5.14.3
|
||||||
|
# via
|
||||||
|
# jupyter-core
|
||||||
|
# nbformat
|
||||||
|
ty==0.0.1a20
|
||||||
|
# via carousel (pyproject.toml:dev)
|
||||||
|
typing-extensions==4.13.2
|
||||||
|
# via
|
||||||
|
# altair
|
||||||
|
# openai
|
||||||
|
# pydantic
|
||||||
|
# pydantic-core
|
||||||
|
# pyright
|
||||||
|
# typing-inspection
|
||||||
|
typing-inspection==0.4.1
|
||||||
|
# via pydantic
|
||||||
|
tzdata==2025.2
|
||||||
|
# via faker
|
||||||
|
uvicorn==0.35.0
|
||||||
|
# via marimo
|
||||||
|
websockets==15.0.1
|
||||||
|
# via marimo
|
||||||
5
shell.nix
Normal file
5
shell.nix
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{ pkgs ? import <nixpkgs> {}}:
|
||||||
|
|
||||||
|
pkgs.mkShellNoCC {
|
||||||
|
packages = with pkgs; [ just uv ];
|
||||||
|
}
|
||||||
57
src/carousel/__init__.py
Normal file
57
src/carousel/__init__.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
from .def_acc import *
|
||||||
|
|
||||||
|
|
||||||
|
def check_match_unstable(
|
||||||
|
match,
|
||||||
|
applicant_prefs,
|
||||||
|
position_prefs,
|
||||||
|
capacities,
|
||||||
|
*,
|
||||||
|
app_col: str = "applicant",
|
||||||
|
pos_col: str = "position",
|
||||||
|
rank_col: str = "rank",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Check match stability between applicants and positions.
|
||||||
|
|
||||||
|
parameters
|
||||||
|
---
|
||||||
|
match: pl.DataFrame
|
||||||
|
| applicant | position |
|
||||||
|
|
||||||
|
applicant_prefs: pl.DataFrame
|
||||||
|
| applicant | position | rank |
|
||||||
|
|
||||||
|
position_prefs: pl.DataFrame
|
||||||
|
| position | applicant | rank |
|
||||||
|
|
||||||
|
"""
|
||||||
|
pass # TODO
|
||||||
|
|
||||||
|
|
||||||
|
def check_match_stable(
|
||||||
|
match,
|
||||||
|
applicant_prefs,
|
||||||
|
position_prefs,
|
||||||
|
capacities,
|
||||||
|
*,
|
||||||
|
app_col: str = "applicant",
|
||||||
|
pos_col: str = "position",
|
||||||
|
rank_col: str = "rank",
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check match stability between applicants and positions.
|
||||||
|
|
||||||
|
parameters
|
||||||
|
---
|
||||||
|
match: pl.DataFrame
|
||||||
|
| applicant | position |
|
||||||
|
|
||||||
|
applicant_prefs: pl.DataFrame
|
||||||
|
| applicant | position | rank |
|
||||||
|
|
||||||
|
position_prefs: pl.DataFrame
|
||||||
|
| position | applicant | rank |
|
||||||
|
|
||||||
|
"""
|
||||||
|
pass # TODO
|
||||||
137
src/carousel/def_acc.py
Normal file
137
src/carousel/def_acc.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
from collections import deque
|
||||||
|
import heapq
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
|
||||||
|
def GS_deferred_acceptance(
|
||||||
|
applicant_prefs: pl.DataFrame,
|
||||||
|
position_prefs: pl.DataFrame,
|
||||||
|
capacities: pl.DataFrame,
|
||||||
|
*,
|
||||||
|
app_col: str = "applicant",
|
||||||
|
pos_col: str = "position",
|
||||||
|
rank_col: str = "rank",
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
"""
|
||||||
|
Compute the proposer-optimal Gale-Shapley deferred acceptance stable matching for a
|
||||||
|
college-admissions problem between "applicants" and "positions" with specified capacities.
|
||||||
|
|
||||||
|
parameters
|
||||||
|
---
|
||||||
|
applicant_prefs: pl.DataFrame
|
||||||
|
A 3-column ranking of positions by applicants. | applicant | position | rank |
|
||||||
|
(lower rank is more preferred).
|
||||||
|
|
||||||
|
|
||||||
|
position_prefs: pl.DataFrame
|
||||||
|
A 3-column ranking of applicants by positions. | position | applicant | rank |
|
||||||
|
(lower rank is more preferred).
|
||||||
|
|
||||||
|
capacities: pl.DataFrame
|
||||||
|
A listing of position capacities. | position | capacity |
|
||||||
|
|
||||||
|
returns
|
||||||
|
---
|
||||||
|
matches: pl.DataFrame
|
||||||
|
A two-column match between applicants and positions (e.g. students and colleges).
|
||||||
|
| applicant | position |
|
||||||
|
"""
|
||||||
|
|
||||||
|
app_idxs = (
|
||||||
|
applicant_prefs.select(app_col).unique().sort(app_col).with_row_index("app_idx")
|
||||||
|
)
|
||||||
|
|
||||||
|
pos_idxs = (
|
||||||
|
position_prefs.select(pos_col).unique().sort(pos_col).with_row_index("pos_idx")
|
||||||
|
)
|
||||||
|
|
||||||
|
n_apps = app_idxs.height
|
||||||
|
n_poss = pos_idxs.height
|
||||||
|
|
||||||
|
ap = (
|
||||||
|
applicant_prefs.join(app_idxs, on=app_col)
|
||||||
|
.join(pos_idxs, on=pos_col)
|
||||||
|
.sort(["app_idx", rank_col])
|
||||||
|
)
|
||||||
|
al = (
|
||||||
|
ap.group_by("app_idx", maintain_order=True)
|
||||||
|
.agg(pl.col("pos_idx"))
|
||||||
|
.sort("app_idx")
|
||||||
|
)
|
||||||
|
app_prefs = al["pos_idx"].to_list()
|
||||||
|
|
||||||
|
max_pref_len = max((len(x) for x in app_prefs), default=0)
|
||||||
|
|
||||||
|
pmat = np.full((n_apps, max_pref_len), -1, dtype=np.int32)
|
||||||
|
for i, r in enumerate(app_prefs):
|
||||||
|
pmat[i, : len(r)] = r
|
||||||
|
|
||||||
|
pp = (
|
||||||
|
position_prefs.join(app_idxs, on=app_col)
|
||||||
|
.join(pos_idxs, on=pos_col)
|
||||||
|
.sort(["pos_idx", rank_col])
|
||||||
|
)
|
||||||
|
worst_rank = np.iinfo(np.int32).max
|
||||||
|
|
||||||
|
ranking = np.full((n_poss, n_apps), worst_rank, dtype=np.int32)
|
||||||
|
for r in pp.iter_rows(named=True):
|
||||||
|
ranking[r["pos_idx"], r["app_idx"]] = r[rank_col]
|
||||||
|
|
||||||
|
caps = capacities.join(pos_idxs, on=pos_col).sort("pos_idx")
|
||||||
|
cap = caps["capacity"].to_numpy().astype(np.int32)
|
||||||
|
|
||||||
|
# ---
|
||||||
|
|
||||||
|
next_c = np.zeros(n_apps, dtype=np.int32)
|
||||||
|
matched_pos = np.full(n_apps, -1, dtype=np.int32)
|
||||||
|
|
||||||
|
free = deque(np.arange(n_apps, dtype=np.int32))
|
||||||
|
|
||||||
|
pos_heaps: list[list[tuple[int, int]]] = [[] for _ in range(n_poss)]
|
||||||
|
|
||||||
|
while free:
|
||||||
|
a = free.popleft()
|
||||||
|
|
||||||
|
while next_c[a] < max_pref_len:
|
||||||
|
p = pmat[a, next_c[a]]
|
||||||
|
next_c[a] += 1
|
||||||
|
|
||||||
|
if p == -1:
|
||||||
|
break
|
||||||
|
# else
|
||||||
|
|
||||||
|
arank = ranking[p, a]
|
||||||
|
|
||||||
|
if arank == worst_rank:
|
||||||
|
continue
|
||||||
|
|
||||||
|
heap = pos_heaps[p]
|
||||||
|
|
||||||
|
if len(heap) < cap[p]:
|
||||||
|
heapq.heappush(heap, (-arank, a))
|
||||||
|
matched_pos[a] = a
|
||||||
|
break
|
||||||
|
|
||||||
|
worst_neg_rank, worst_app = heap[0]
|
||||||
|
worst_rank_current = -worst_neg_rank
|
||||||
|
|
||||||
|
if arank < worst_rank_current:
|
||||||
|
heapq.heapreplace(heap, (-arank, a))
|
||||||
|
matched_pos[a] = a
|
||||||
|
matched_pos[worst_app] = -1
|
||||||
|
|
||||||
|
free.append(worst_app)
|
||||||
|
break
|
||||||
|
|
||||||
|
matches = (
|
||||||
|
pl.DataFrame({"app_idx": np.arange(n_apps), "pos_idx": matched_pos})
|
||||||
|
.filter(pl.col("pos_idx") != -1)
|
||||||
|
.join(app_idxs, on="app_idx")
|
||||||
|
.join(pos_idxs, on="pos_idx")
|
||||||
|
.select([app_col, pos_col])
|
||||||
|
.sort(app_col)
|
||||||
|
)
|
||||||
|
|
||||||
|
return matches
|
||||||
45
src/carouselcmd.py
Normal file
45
src/carouselcmd.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.13"
|
||||||
|
# dependencies = [
|
||||||
|
# "click>=8.4.1",
|
||||||
|
# "rich>=15.0.0",
|
||||||
|
# "polars>=1.40.1",
|
||||||
|
# "rustworkx>=0.17.1"
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
import sys, io
|
||||||
|
import click
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.panel import Panel
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
from carousel import match_from_prefs
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.argument("preferences", type=click.Path(exists=True, dir_okay=False))
|
||||||
|
@click.option(
|
||||||
|
"--show-preferences",
|
||||||
|
"-b",
|
||||||
|
is_flag=True,
|
||||||
|
help="Show relevant preferences (after selections).",
|
||||||
|
)
|
||||||
|
@click.option("--pretty", "-p", is_flag=True, help="Pretty-print output.")
|
||||||
|
def cli(preferences: str, show_ballots=False, pretty=False) -> None:
|
||||||
|
"""
|
||||||
|
Compute the Gale-Shapley stable match of some preferences -- .csv or .xls(x).
|
||||||
|
|
||||||
|
A stable matching (of the "college admissions" problem) is one in which there is no
|
||||||
|
pair of, say, TA and course which would prefer each other over their respective
|
||||||
|
assignment given by the matching.
|
||||||
|
"""
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
||||||
10
src/carouselgui.py
Normal file
10
src/carouselgui.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
from PySide6.QtWidgets import QApplication, QWidget
|
||||||
|
import sys
|
||||||
|
import carousel
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
|
||||||
|
window = QWidget()
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
app.exec()
|
||||||
12
src/cgi/carousel.cgi
Executable file
12
src/cgi/carousel.cgi
Executable file
|
|
@ -0,0 +1,12 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
if [[ "$QUERY_STRING" == "source" ]]; then
|
||||||
|
echo "Content-Type: text/plain"
|
||||||
|
echo
|
||||||
|
sed 's/&/\&/g; s/</\</g; s/>/\>/g' "$0"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$SCRIPT_DIR/../../.venv/bin/python" "$SCRIPT_DIR/script.py"
|
||||||
75
src/cgi/form.html
Normal file
75
src/cgi/form.html
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title> Carousel - Stable Matcher </title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
input[type="file"] {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
input[type="submit"] {
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Carousel: Stable Matcher - CGI Application Upload</h1>
|
||||||
|
|
||||||
|
<div class="form-container">
|
||||||
|
<form action="./carousel.cgi" method="POST" enctype="multipart/form-data">
|
||||||
|
<label for="spreadsheet">Upload an assignment preference spreadsheet (.xlsx, .xls, .csv):</label>
|
||||||
|
<input type="file" id="spreadsheet" name="spreadsheet" accept=".xlsx,.xls,.csv" required>
|
||||||
|
<br>
|
||||||
|
<input type="submit" value="Upload and Match!">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="explanation-container">
|
||||||
|
|
||||||
|
<h2>About</h2>
|
||||||
|
<p>This is an upload form for the <a href="https://github.com/tgorordo/carousel">carousel</a> stable matcher for ,
|
||||||
|
mainly to help with <a href="https://blogs.uoregon.edu/physicsgsg/">UO Physics TA assignments</a>.
|
||||||
|
This form uses the <a href="https://service.uoregon.edu/TDClient/2030/Portal/KB/ArticleDet?ID=43069">UO pages.uoregon.edu CGI Capability</a>,
|
||||||
|
so the implementation of smithy being invoked can be <a href="https://pages.uoregon.edu/tgorordo/files/carousel/"> inspected here</a>
|
||||||
|
and you may also inspect the source of this page to verify that <a href="https://pages.uoregon.edu/tgorordo/files/carousel/src/cgi/carousel.cgi?source">this script<a> is called to invoke it - though
|
||||||
|
you have to trust it to <a href="https://en.wikipedia.org/wiki/Quine_(computing)">quine</a> itself faithfully.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p> A <a href=https://en.wikipedia.org/wiki/Stable_matching_problem>stable matching (of the "college admissions" problem)</a> is one in which there is no pair of, say, TA and course which would prefer
|
||||||
|
each other over their respective assignment given by the matching. This form runs a version of the
|
||||||
|
<a href="https://en.wikipedia.org/wiki/Gale%E2%80%93Shapley_algorithm">Gale-Shapley (1962) deferred-acceptance algorithm</a>, which finds the stable matching
|
||||||
|
that is optimal for the TA preferrences. Other stable solutions can be found by using <a href="https://github.com/tgorordo/carousel">the underlying
|
||||||
|
python application manually</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Input: Expected Spreadsheet Format</h3>
|
||||||
|
<p>A matching-preference spreadsheet should be organized as a list of TA assignment preferences
|
||||||
|
and a list of course preferences/constraints, i.e. with the following structure:
|
||||||
|
TODO</p>
|
||||||
|
|
||||||
|
<h3>Output: Matching Format</h3>
|
||||||
|
<p>The form will return a matching of TAs to course assignments of the form: TODO</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
<footer>
|
||||||
|
<hr>
|
||||||
|
<p>Author: <a href="https://pages.uoregon.edu/tgorordo">Thomas (Tom) C. Gorordo</a>
|
||||||
|
Source: <a href="https://github.com/tgorordo/pages.uoregon.edu">pages.uoregon.edu/tgorordo</a>,
|
||||||
|
<a href="https://github.com/tgorordo/carousel">carousel</a></p>
|
||||||
|
</footer>
|
||||||
|
</html>
|
||||||
145
src/cgi/script.py
Normal file
145
src/cgi/script.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import traceback
|
||||||
|
import sys, os
|
||||||
|
import html
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
sys.path.insert(
|
||||||
|
0,
|
||||||
|
os.path.abspath(
|
||||||
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "../carousel/src")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
from carousel import match_from_prefs
|
||||||
|
|
||||||
|
print("Content-Type: text/html\n")
|
||||||
|
message = ""
|
||||||
|
|
||||||
|
content_type = os.environ.get("CONTENT_TYPE", "")
|
||||||
|
content_length = int(os.environ.get("CONTENT_LENGTH", 0) or 0)
|
||||||
|
boundary = content_type.split("boundary=")[-1].encode()
|
||||||
|
|
||||||
|
body = sys.stdin.buffer.read(content_length)
|
||||||
|
parts = body.split(b"--" + boundary)
|
||||||
|
|
||||||
|
spreadsheet = None
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
if b"Content-Disposition" in part and b'name="spreadsheet"' in part:
|
||||||
|
header, _, data = part.partition(b"\r\n\r\n")
|
||||||
|
|
||||||
|
filename_match = re.search(rb'filename="([^"]+)"', header)
|
||||||
|
if filename_match:
|
||||||
|
filename = filename_match.group(1).decode()
|
||||||
|
filedata = data.rstrip(b"\r\n--")
|
||||||
|
|
||||||
|
spreadsheet = (filename, filedata)
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
if spreadsheet is not None:
|
||||||
|
filename, filedata = spreadsheet
|
||||||
|
|
||||||
|
if filename and filedata:
|
||||||
|
filepath = os.path.join("/tmp", filename)
|
||||||
|
|
||||||
|
with open(filepath, "wb") as f:
|
||||||
|
f.write(filedata)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if filename.endswith(".csv"):
|
||||||
|
df = pl.read_csv(filepath)
|
||||||
|
elif filename.endswith((".xlsx", ".xls")):
|
||||||
|
df = pl.read_excel(filepath)
|
||||||
|
else:
|
||||||
|
message = """
|
||||||
|
<h1>Error</h1>
|
||||||
|
<p>File extension is not valid. Use CSV (.csv) or Excel (.xlsx, .xls).</p>
|
||||||
|
<p><a href="form.html">Go Back</a></p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO: unpack df appropriately
|
||||||
|
if df is not None:
|
||||||
|
# Normalize
|
||||||
|
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
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
match = match_from_prefs(df) # Solve!
|
||||||
|
|
||||||
|
message = f"""
|
||||||
|
<h1>The TA-optimal (Gale-Shapley) match is:</h1>
|
||||||
|
{match}
|
||||||
|
<p><a href="form.html">Go Back</a></p>
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
message = """
|
||||||
|
<h1>Error</h1>
|
||||||
|
<p>DataFrame was empty.</p>
|
||||||
|
<p><a href="form.html">Go Back</a></p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"""
|
||||||
|
<h1>Error</h1>
|
||||||
|
<p>Internal Error Encountered: {e}
|
||||||
|
<p><a href="form.html">Go Back</a></p>
|
||||||
|
"""
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
else:
|
||||||
|
message = """
|
||||||
|
<h1>Error</h1>
|
||||||
|
<p>Filename or File Data not found/valid in form submission.</p>
|
||||||
|
<p><a href="form.html">Go Back</a></p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
else:
|
||||||
|
message = """
|
||||||
|
<h1>Error</h1>
|
||||||
|
<p>No file field found in the form.</p>
|
||||||
|
<p><a href="form.html">Go Back</a></p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
print("""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title> Carousel - Stable Matcher </title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
""")
|
||||||
|
print(message)
|
||||||
|
print("""
|
||||||
|
</body>
|
||||||
|
<footer>
|
||||||
|
<hr>
|
||||||
|
<p>Author: <a href="https://pages.uoregon.edu/tgorordo">Thomas (Tom) C. Gorordo</a>
|
||||||
|
Source: <a href="https://github.com/tgorordo/pages.uoregon.edu">pages.uoregon.edu/tgorordo</a>,
|
||||||
|
<a href="https://github.com/tgorordo/carousel">carousel</a></p>
|
||||||
|
</footer>
|
||||||
|
</html>
|
||||||
|
""")
|
||||||
80
test/galeshapley_test.py
Normal file
80
test/galeshapley_test.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import polars as pl
|
||||||
|
from polars.testing import assert_frame_equal
|
||||||
|
from carousel import GS_deferred_acceptance
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefs():
|
||||||
|
people_prefs = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"people": [
|
||||||
|
"Alice",
|
||||||
|
"Alice",
|
||||||
|
"Alice",
|
||||||
|
"Bob",
|
||||||
|
"Bob",
|
||||||
|
"Bob",
|
||||||
|
"Charlie",
|
||||||
|
"Charlie",
|
||||||
|
"Charlie",
|
||||||
|
],
|
||||||
|
"fruit": [
|
||||||
|
"apple",
|
||||||
|
"banana",
|
||||||
|
"cherry",
|
||||||
|
"banana",
|
||||||
|
"cherry",
|
||||||
|
"apple",
|
||||||
|
"cherry",
|
||||||
|
"apple",
|
||||||
|
"banana",
|
||||||
|
],
|
||||||
|
"rank": [1, 2, 3, 1, 2, 3, 1, 2, 3],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
fruit_prefs = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"fruit": [
|
||||||
|
"apple",
|
||||||
|
"apple",
|
||||||
|
"apple",
|
||||||
|
"banana",
|
||||||
|
"banana",
|
||||||
|
"banana",
|
||||||
|
"cherry",
|
||||||
|
"cherry",
|
||||||
|
"cherry",
|
||||||
|
],
|
||||||
|
"people": [
|
||||||
|
"Alice",
|
||||||
|
"Bob",
|
||||||
|
"Charlie",
|
||||||
|
"Alice",
|
||||||
|
"Bob",
|
||||||
|
"Charlie",
|
||||||
|
"Alice",
|
||||||
|
"Bob",
|
||||||
|
"Charlie",
|
||||||
|
],
|
||||||
|
"rank": [1, 1, 1, 1, 1, 1, 1, 1, 1], # fruits have no preferences
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
capacities = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"fruit": ["apple", "cherry", "banana"],
|
||||||
|
"capacity": [1, 1, 1], # have one of each
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_frame_equal(
|
||||||
|
GS_deferred_acceptance(
|
||||||
|
people_prefs, fruit_prefs, capacities, app_col="people", pos_col="fruit"
|
||||||
|
).sort(["people", "fruit"]),
|
||||||
|
pl.DataFrame(
|
||||||
|
{
|
||||||
|
"people": ["Alice", "Bob", "Charlie"],
|
||||||
|
"fruit": ["apple", "banana", "cherry"],
|
||||||
|
}
|
||||||
|
).sort(["people", "fruit"]),
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue