Constraint-based arrangement engine for group rotation, timetabling, and any scheduling problem with rules.
Project description
quench
Arrange people into groups — or schedule things into time slots — while following rules you define.
Built for problems like:
- Group rotation — cycle 20 kids through 4 activity groups over several sessions, making sure the same kids don't keep ending up together
- Timetabling — assign 10 classes across rooms and periods so no student is double-booked and total walking distance is minimised
- Any arrangement problem — staff shifts with "do not pair" rules, sports teams with balance constraints, meeting rooms with attendee requirements
The engine finds the best arrangement it can given your rules. You define what "best" means.
Requirements
Python 3.7+. The core engine has zero external dependencies — pure standard library.
Install from PyPI (once published)
pip install quench
Install from source
git clone https://github.com/OhmHobby/quench.git
cd quench
pip install -e .
To run the tests: pip install pytest && pytest tests/
Try it immediately
Three working examples are included. Run any from the project root:
python examples/group_rotation.py
python examples/timetable.py
python examples/pharmacy/pharmacy_schedule.py
Group rotation
20 kids, 4 activity groups of 5, run across 3 sessions. The engine minimises repeat pairings and role repetition between sessions.
Session 1
Group-Leader Bob, Grace, Hank, Jack, Tina
Group-Navigator Frank, Iris, Leo, Mia, Noah
Group-Timekeeper Alice, Carol, Olivia, Paul, Rose
Group-Scribe Dan, Eve, Kate, Quinn, Sam
Session 2 (cost reduced 53.8%)
Group-Leader Alice, Eve, Grace, Noah, Sam
...
Summary: 0 repeat group pairs across 3 sessions
8 role repeats out of 60 possible
Timetable
10 courses across a 3x3 room grid and 4 periods. Hard constraints prevent double-booking and student conflicts. The soft constraint minimises total student walking distance.
A1 A2 A3 B1 B2 B3 C1 C2 C3
P1 — — — — PE Science — — —
P2 — — — Art Music Math — — —
P3 — — — Drama English History — — —
P4 — — — — CS Geo — — —
Pharmacy schedule (real-world)
15 inpatient pharmacists assigned to 5 task sections (S, M, L, C, V) across a 4-week month. Based on a real customer request.
python examples/pharmacy/pharmacy_schedule.py
Rules enforced:
- พัชรสิรินทร์ and ศศิลักษณ์ must never be in the same task section (hard)
- รุ่งโรจน์ fixed at M4, อรุณี fixed at L3 every week (hard)
- Staff rotate through different task sections and sub-positions over time (soft)
- Pairs who have worked together recently are separated (soft)
Sample output:
Week 1
S (Satellite)
S1 สิริบุญ
S2 ประภวิษณุ
...
M (Main)
M4 รุ่งโรจน์ [fixed]
...
Separation check
Week 1: พัชรสิรินทร์=L ศศิลักษณ์=C ✓
Week 2: พัชรสิรินทร์=S ศศิลักษณ์=M ✓
...
Result: All weeks passed ✓
Exports schedule_output.csv in the same column format as the original schedule file.
Build your own
Step 1 — Define entities and slots
Entities are the things being assigned (people, courses, jobs). Slots are the places they go (groups, rooms, time periods, roles).
from quench import Entity, Slot
people = [Entity("Alice"), Entity("Bob"), Entity("Carol"), Entity("Dan")]
groups = [Slot("Morning", capacity=2), Slot("Afternoon", capacity=2)]
Both support optional metadata to carry domain data into constraints:
Slot("Room-B2", capacity=30, meta={"floor": 2, "projector": True})
Entity("Alice", meta={"department": "Engineering", "seniority": 3})
Step 2 — Write your constraints
Hard constraints are rules that must never be broken. The engine rejects any arrangement that violates one — full stop.
from quench import hard
def no_overflow(state):
# return True if the constraint is VIOLATED
return any(len(state.group(g)) > g.capacity for g in groups)
capacity_rule = hard(no_overflow, name="capacity")
Soft constraints are goals to minimise — the engine reduces them as much as possible.
from quench import soft
def prefer_separate(state):
alice = Entity("Alice")
bob = Entity("Bob")
return 1.0 if state[alice] == state[bob] else 0.0
separation_rule = soft(prefer_separate, weight=5.0, name="alice_bob_apart")
weight controls relative importance. A weight of 5.0 means this constraint matters five times more than one with weight 1.0.
Step 3 — Solve
from quench import Scorer, Solver
scorer = Scorer([capacity_rule, separation_rule])
solver = Solver(people, groups, scorer, init="balanced")
result = solver.solve(seed=42)
print(result)
# Result(SA, feasible, cost=0.000, improvement=100.0%, iters=50,000)
print(result.breakdown(scorer))
# {'capacity': 0.0, 'alice_bob_apart': 0.0}
for g in groups:
members = [e.id for e in result.best_state.group(g)]
print(f"{g.id}: {', '.join(members)}")
# Morning: Alice, Carol
# Afternoon: Bob, Dan
Multiple sessions with history
For group rotation and repeated scheduling problems, the engine can remember who has been paired before and avoid repeating those pairings.
from quench import History
history = History()
for session in range(1, 4):
scorer = Scorer([
hard(no_overflow, name="capacity"),
history.as_soft_constraint(weight=3.0), # penalise repeat pairings
])
solver = Solver(people, groups, scorer, init="balanced")
result = solver.solve(seed=session)
print(f"\nSession {session}")
for g in groups:
print(f" {g.id}: {[e.id for e in result.best_state.group(g)]}")
history.update(result.best_state) # record who was grouped this session
The cost will naturally rise across sessions — that is expected and correct. As more pairings get recorded, fewer fresh combinations remain, so the minimum achievable cost goes up. What matters is that the engine keeps finding the best available arrangement.
History persists between runs:
history.save("history.json") # save after a session
history = History.load("history.json") # restore in the next run
Use string entity IDs (
Entity("1")notEntity(1)) if you plan to save history. JSON coerces integer keys to strings on load, which breaks lookups.
Reading the result
result.best_state # the final arrangement
result.best_cost # total penalty score — lower is better, 0.0 is perfect
result.feasible # True if no hard constraint was violated
result.improvement_pct # % cost reduction from the starting arrangement
result.converged # rough signal: did cost flatten in the final 20% of the run?
result.breakdown(scorer) # per-constraint cost breakdown as a dict
Solver options
Engine
Solver(..., engine="auto") # default: SA for <40 entities, PT for >=40
Solver(..., engine="sa") # Simulated Annealing — fast, good for smaller problems
Solver(..., engine="pt") # Parallel Tempering — better for large or complex problems
Starting arrangement
Solver(..., init="balanced") # round-robin distribution — recommended for equal-size groups
Solver(..., init="random") # random distribution — use with the move neighbor function
Important: if your groups all have equal capacity, always use init="balanced". The default swap operation preserves group sizes — an unbalanced starting arrangement will stay unbalanced forever because no sequence of swaps can fix it.
Tuning SA
Solver(..., engine="sa", T0=80.0, alpha=0.997, iterations=40_000)
| Parameter | Effect |
|---|---|
T0 |
Starting temperature. Higher = more exploration early on. |
alpha |
Cooling rate (0–1). Closer to 1 = slower cooling = longer run, better result. |
iterations |
Total steps. Increase if the result is still improving at the end. |
Tuning PT
Solver(..., engine="pt", T_min=0.1, T_max=100.0, n_chains=6, iterations=50_000)
Neighbor function
Controls how the engine proposes a new arrangement at each step:
| Function | Behaviour | Use when |
|---|---|---|
swap (default) |
Exchange two entities' slots | Equal-size groups required |
move |
Reassign one entity to a random slot | Variable group sizes |
mixed |
70% swap, 30% move | Soft capacity constraints |
make_swap_k(k) |
Swap k pairs simultaneously | Large problems, hot exploration |
from quench import move, make_swap_k
Solver(..., neighbor_fn=move)
Solver(..., neighbor_fn=make_swap_k(3)) # swap 3 pairs per step
Multiple independent solutions
results = solver.sample(n=5, seed=0) # 5 independent runs, sorted best-first
best = results[0]
top3 = results[:3]
What the engine cannot guarantee
- It may not find the global optimum. SA and PT are heuristics — they search well but do not prove optimality. For problems with fewer than ~100 entities, an exact solver (like Google OR-Tools) would find the true optimum. This library trades proof for flexibility.
- Hard constraints must be satisfiable. If your hard constraints make every arrangement infeasible (for example, requiring 12 people in 10 groups of 1), the engine will return
result.feasible == Falseandbest_cost == inf. - More iterations generally means better results. If you're not satisfied with the output, try increasing
iterationsor loweringalpha(SA) before anything else.
Running the tests
pytest tests/
170 tests covering state manipulation, constraints, scoring, engines, history, and edge cases. All pass in under a second.
Project layout
quench/ Engine and data structures — installable package
examples/ Working end-to-end examples
pharmacy/ Real-world pharmacy rotation case
tests/ Unit and regression tests
pyproject.toml Package metadata — pip install -e .
Not a developer?
quench currently requires writing Python to define your constraints. If that's a barrier, two things are on the roadmap:
- Natural language interface — describe your rules in plain English, let a language model turn them into constraints and explain the result back to you
- Web UI — a form-based interface for common scheduling problems, no code needed
See ROADMAP.md for details.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file quench-0.1.0.tar.gz.
File metadata
- Download URL: quench-0.1.0.tar.gz
- Upload date:
- Size: 35.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
39567dcca749891e38b79ba3e5111f5d42ab59d672b7b14966a317d56c24e074
|
|
| MD5 |
730331706a737e384b7d4aaee3fae1a3
|
|
| BLAKE2b-256 |
eb21f6e665819e1206a82f8bcc2202b846bddc98e1e2b295051f719ece7e42d9
|
File details
Details for the file quench-0.1.0-py3-none-any.whl.
File metadata
- Download URL: quench-0.1.0-py3-none-any.whl
- Upload date:
- Size: 25.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dc6dff24ef7542e2b4576e64e7525d90a0d82145e41bfbb983a336d3d7b795f7
|
|
| MD5 |
41ecfec825ee40c6147b5f22640edcba
|
|
| BLAKE2b-256 |
a5c5d1477670e890cd878095ed8d20a2a3d18be89231c3b0f89ddef6558ab5d5
|