init commit. modeled on tgorordo/smithy, still lots TODO

This commit is contained in:
Thomas (Tom) C. Gorordo 2026-05-29 07:10:47 -07:00
commit 8bc048c0ee
Signed by: tgorordo
GPG key ID: 0CBED22BB0D94490
14 changed files with 2659 additions and 0 deletions

11
README.md Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,5 @@
{ pkgs ? import <nixpkgs> {}}:
pkgs.mkShellNoCC {
packages = with pkgs; [ just uv ];
}

57
src/carousel/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g' "$0"
exit 0
fi
exec "$SCRIPT_DIR/../../.venv/bin/python" "$SCRIPT_DIR/script.py"

75
src/cgi/form.html Normal file
View 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
View 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
View 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"]),
)

1811
uv.lock generated Normal file

File diff suppressed because it is too large Load diff