Back Original

New Life Hack: Using LLMs to Generate Constraint Solver Programs for Personal Logistics Tasks

I enjoy doing escape rooms and was planning to do a couple of them with a group of friends this weekend. The very minor and not-very-important challenge, however, was that I couldn't figure out how to assign friends to rooms. I want to do at least one room with each person, different people are arriving and leaving at different times, and there are only so many time slots. Both Claude 3.7 Sonnet and ChatGPT o3 tried and failed to figure out a workable solution given my constraints. However, after asking the LLMs to generate a constraint solver program and massaging the constraints a bit, I was able to find a solution.

TL;DR: If you need help juggling between a bunch of constraints for some personal logistical task, try asking an LLM to translate your requirements into a constraint solver program. You'll quickly find if there exists a solution and, if not, you can edit the constraints to find the best approach.

Background

The escape room location we're going to is PuzzleConnect in New Jersey. They have 3 rooms that are all Overwhelmingly Positively rated on Morty, which practically guarantees that they're going to be good.

Side note on Morty: Escape rooms as a category are fun but very hit-or-miss. Knowing which rooms are going to be great and which aren't is a game changer because you can skip the duds. Morty is an app specifically for reviewing escape rooms, and it shows you how many rooms each reviewer has played. If someone who has gone out of their way to play 100+ escape rooms says a room is great, you can probably take their word for it. If you happen to be in the Netherlands, Belgium, or Luxembourg, EscapeTalk.nl is also fantastic.

Side side note: I'm not affiliated with Morty or EscapeTalk in any way. I just appreciate what they do.

Constraints

As you can see, these are a fair number of constraints to juggle. I started with pen and paper, then a spreadsheet, but quickly gave up and asked the LLMs.

Constraint Solvers

Constraint solvers are programs in which you declaratively express constraints on the solution and the solver effectively tries to explore all possible states to find a valid solution. In practice, they use lots of clever methods and heuristics to efficiently explore the state space.

Unlike with imperative programming where you specify the steps for the program to take, under this paradigm you are just describing a valid final state. In addition to specifying hard constraints, you can also provide soft constraints that the solver will attempt to maximize.

I would not have thought to ask the LLMs to build a constraint solver program for me if not for Carolyn Zech's talk at this week's Rust NYC meetup about verifying the Rust standard library (see the announcement and the project on Github).

Escaping the Escape Room Assignment Puzzle

I have no experience writing programs for constraint solvers, but I was able to describe all of my constraints and ChatGPT was perfectly capable of translating those requirements into code.

In this case, we used Google's OR-Tools python package.

The first version was impossible to satisfy, but it worked once I added and moved around some time slots. After finding a workable solution, I think it's interesting to note how hard and soft constraints are expressed.

For example, each player can only play each room once:

# ---------------------------------------------------------------------------
# 2.2  One session per theme for each player (no repeats)
# ---------------------------------------------------------------------------
for p in players:
    for theme in {s[1] for s in sessions}:
        same_theme = [i for i, s in enumerate(sessions) if s[1] == theme]
        if len(same_theme) > 1:
            model.Add(sum(x[p][i] for i in same_theme) <= 1)

Or, my desire to play at least one room with each person expressed as a soft constraint looks like this:

# Nice 3 – E plays with everyone at least once
if NICE_E_WITH_ALL:
    for q in players:
        if q == "E":
            continue
        together = []
        for i in range(num_sessions):
            both = model.NewBoolVar(f"E_{q}_{i}")
            model.Add(both <= x["E"][i])
            model.Add(both <= x[q][i])
            model.Add(both >= x["E"][i] + x[q][i] - 1)
            together.append(both)
        meet_flag = model.NewBoolVar(f"E_with_{q}")
        model.AddMaxEquality(meet_flag, together)
        obj_terms.append(meet_flag)

You can find the full code below.

Conclusion

I'm not a big fan of balancing logistical tasks and constraints, but I do like finding optimal solutions. Getting an LLM to generate a constraint solver program for me to find optimal or feasible solutions is a nice new life hack that I'm sure I'll be using again.

ChatGPT can run Python code that it generates for you, but ortools isn't available as an import (for now!). It would be neat if OpenAI or Anthropic added it as a dependency and trained the models to reach for it when given some set of hard constraints or an optimization problem to solve. In the meantime, though, I'll just use uv run --with ortools ... to optimize random life logistics.

Code

from ortools.sat.python import cp_model
from datetime import datetime

# ---------------------------------------------------------------------------
# Helper to convert "HH:MM" ➜ minutes since 00:00
# ---------------------------------------------------------------------------

def t(hhmm: str) -> int:
    h, m = map(int, hhmm.split(":"))
    return h * 60 + m

# ---------------------------------------------------------------------------
# 1)  INPUT DATA  ─── tweak these dictionaries / lists as you like
# ---------------------------------------------------------------------------

players = ["A", "B", "D", "E", "J", "P", "S", "T"]

# (min, max) rooms each player must play
quota = {
    "A": (2, 2),
    "B": (2, 2),
    "S": (2, 3), 
    "D": (3, 3),
    "E": (3, 3),
    "J": (3, 3),
    "P": (3, 3),
    "T": (3, 3),
}

# (label, theme, start‑time, end‑time)  – freely add / remove sessions
sessions = [
    ("Candy1", "Candy", t("11:00"), t("11:50")),
    ("Candy2", "Candy", t("12:00"), t("12:50")),
    ("Candy3", "Candy", t("13:00"), t("13:50")),

    ("Xmas1",  "Christmas", t("11:00"), t("11:50")),
    ("Xmas2",  "Christmas", t("12:00"), t("12:50")),
    ("Xmas3",  "Christmas", t("13:00"), t("13:50")),
    ("Xmas4",  "Christmas", t("14:00"), t("14:50")),

    ("Temple1", "Temple", t("11:00"), t("11:50")),
    ("Temple2", "Temple", t("12:00"), t("12:50")),
    ("Temple3", "Temple", t("13:00"), t("13:50")),
    ("Temple4", "Temple", t("14:00"), t("14:50")),
    # ("Temple5", "Temple", t("15:00"), t("15:50")),
]

# Nice‑to‑haves – set False if you do not care
NICE_EDT_T2       = True   # E, D, T together in Temple2
NICE_AB_TOGETHER  = True   # A & B share at least one session
NICE_E_WITH_ALL   = True   # E meets everyone at least once

# Arrival / departure windows
arrival = {p: t("11:00") for p in players}
arrival.update({"A": t("12:15"), "B": t("12:15"), "S": t("12:15")})
depart = {p: t("23:59") for p in players}
depart.update({"P": t("15:30"), "T": t("15:30")})

# Capacity limits (min only applies if the session is actually used)
CAP_MIN = 2
CAP_MAX = 3

# ---------------------------------------------------------------------------
# 2)  CP‑SAT MODEL
# ---------------------------------------------------------------------------

model = cp_model.CpModel()
num_sessions = len(sessions)

# x[p][i] == 1  ⇔ player p attends session i
x = {
    p: [model.NewBoolVar(f"x[{p},{i}]") for i in range(num_sessions)]
    for p in players
}

# y[i] == 1 ⇔ session i is actually used (≥1 player)
y = [model.NewBoolVar(f"used[{i}]") for i in range(num_sessions)]

# ---------------------------------------------------------------------------
# 2.1  Quotas
# ---------------------------------------------------------------------------
for p in players:
    lo, hi = quota[p]
    model.Add(sum(x[p][i] for i in range(num_sessions)) >= lo)
    model.Add(sum(x[p][i] for i in range(num_sessions)) <= hi)

# ---------------------------------------------------------------------------
# 2.2  One session per theme for each player (no repeats)
# ---------------------------------------------------------------------------
for p in players:
    for theme in {s[1] for s in sessions}:
        same_theme = [i for i, s in enumerate(sessions) if s[1] == theme]
        if len(same_theme) > 1:
            model.Add(sum(x[p][i] for i in same_theme) <= 1)

# ---------------------------------------------------------------------------
# 2.3  Arrival / departure filtering
# ---------------------------------------------------------------------------
for p in players:
    for i, (_, _, start, end) in enumerate(sessions):
        if start < arrival[p] or end > depart[p]:
            model.Add(x[p][i] == 0)

# ---------------------------------------------------------------------------
# 2.4  No overlaps per player
# ---------------------------------------------------------------------------
for p in players:
    for i in range(num_sessions):
        si, ei = sessions[i][2:4]
        for j in range(i + 1, num_sessions):
            sj, ej = sessions[j][2:4]
            if si < ej and sj < ei:  # true overlap (shared minutes)
                model.Add(x[p][i] + x[p][j] <= 1)

# ---------------------------------------------------------------------------
# 2.5  Link "used" variable with player attendance + capacity bounds
# ---------------------------------------------------------------------------
for i in range(num_sessions):
    total_here = sum(x[p][i] for p in players)
    model.Add(total_here >= CAP_MIN).OnlyEnforceIf(y[i])
    model.Add(total_here <= CAP_MAX)
    # If anyone attends ➜ session is used
    for p in players:
        model.Add(x[p][i] <= y[i])
    # At least 1 attend => used=1  (big‑M style)
    model.Add(total_here >= 1).OnlyEnforceIf(y[i])
    model.Add(total_here <= 0).OnlyEnforceIf(y[i].Not())

# ---------------------------------------------------------------------------
# 3)  SOFT CONSTRAINTS  /  OBJECTIVE FUNCTION
# ---------------------------------------------------------------------------
obj_terms = []

# Nice 1 – E, D, T together in Temple2
if NICE_EDT_T2:
    idx_t2 = next(i for i, s in enumerate(sessions) if s[0] == "Temple2")
    edt = model.NewBoolVar("edt_in_T2")
    model.Add(x["E"][idx_t2] + x["D"][idx_t2] + x["T"][idx_t2] >= 3 * edt)
    model.Add(x["E"][idx_t2] + x["D"][idx_t2] + x["T"][idx_t2] <= edt + 2)
    obj_terms.append(edt)

# Nice 2 – at least one game with A & B together
if NICE_AB_TOGETHER:
    ab_shared = model.NewBoolVar("A_B_together")
    ab_in_any = []
    for i in range(num_sessions):
        s_var = model.NewBoolVar(f"AB_{i}")
        model.Add(s_var <= x["A"][i])
        model.Add(s_var <= x["B"][i])
        model.Add(s_var >= x["A"][i] + x["B"][i] - 1)
        ab_in_any.append(s_var)
    model.AddMaxEquality(ab_shared, ab_in_any)
    obj_terms.append(ab_shared)

# Nice 3 – E plays with everyone at least once
if NICE_E_WITH_ALL:
    for q in players:
        if q == "E":
            continue
        together = []
        for i in range(num_sessions):
            both = model.NewBoolVar(f"E_{q}_{i}")
            model.Add(both <= x["E"][i])
            model.Add(both <= x[q][i])
            model.Add(both >= x["E"][i] + x[q][i] - 1)
            together.append(both)
        meet_flag = model.NewBoolVar(f"E_with_{q}")
        model.AddMaxEquality(meet_flag, together)
        obj_terms.append(meet_flag)

# Nice 4 – at least one game with T & P together
TP_shared = model.NewBoolVar("T_P_together")
tp_in_any = []
for i in range(num_sessions):
    s_var = model.NewBoolVar(f"TP_{i}")
    model.Add(s_var <= x["T"][i])
    model.Add(s_var <= x["P"][i])
    model.Add(s_var >= x["T"][i] + x["P"][i] - 1)
    tp_in_any.append(s_var)
model.AddMaxEquality(TP_shared, tp_in_any)
obj_terms.append(TP_shared)

# Maximise the total number of soft constraints satisfied
if obj_terms:
    model.Maximize(sum(obj_terms))

# ---------------------------------------------------------------------------
# 4)  SOLVE & PRINT RESULT
# ---------------------------------------------------------------------------

solver = cp_model.CpSolver()
solver.parameters.max_time_in_seconds = 60  # increase if needed
status = solver.Solve(model)

if status in (cp_model.OPTIMAL, cp_model.FEASIBLE):
    print("\n✔ Schedule found (soft‑score =", solver.ObjectiveValue(), ")\n")
    def hm(m):
        return f"{m // 60:02d}:{m % 60:02d}"

    for i, (label, theme, start, end) in enumerate(sessions):
        ppl = [p for p in players if solver.Value(x[p][i])]
        if ppl:
            print(f"{theme:<10} {label:<7} {hm(start)}{hm(end)} :  {', '.join(ppl)}")
else:
    print("\n✘ No feasible schedule under the current hard constraints.")

#ai