- Python 84.7%
- HTML 10.3%
- CSS 2%
- JavaScript 1.8%
- Nix 1.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Whole-roster scheduling through OR-Tools CP-SAT, producing proposals the coach reviews and approves. Approval writes to Postgres only; the Cal.com adapter is still Phase 3. Every mentee also gains a 20-minute onboarding call before their six biweekly sessions, stored as ordinal 0 so "session 3 of 6" keeps its meaning on every page and in every stored audit event. Five decisions not obvious from the diff: - Three of the brief's hard constraints are enforced as objectives. Requiring every enrollment to be assigned turns an over-subscribed roster INFEASIBLE, so the coach gets nothing rather than a schedule plus a named problem. Same for incumbent moves and reserve protection; the literals survive only to drive the diagnosis ladder. - only_enforce_if([]) is unconditional, not vacuous. An empty literal list hard-codes the constraint it was meant to relax, and the symptom is a model that gets stricter the more you relax it. - Starts can be deferred by whole slot periods. Without that, a series whose next occurrence is days away leaves no room for the call that must precede it, and since A and B weeks alternate that halves usable capacity. Stage 4 prices the wait. - Keeping a slot means keeping its existing times, not re-materializing from today, or "minimize incumbent moves" measures calendar drift. - Approval applies the solver's own timestamps. Re-deriving them is a second opinion against a roster that has moved on since review. Migrations 0004 (solver_run, change_proposal) and 0005 (session ordinal widened to 0-6). devenv now puts libstdc++ and libz on LD_LIBRARY_PATH: the ortools manylinux wheel needs both, and the failure surfaces one layer down as a numpy import error. |
||
| .vscode | ||
| app | ||
| img | ||
| migrations | ||
| tests | ||
| .env.example | ||
| .envrc | ||
| .gitignore | ||
| alembic.ini | ||
| coaching-problem.md | ||
| devenv.lock | ||
| devenv.nix | ||
| devenv.yaml | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
mentor-scheduler
Self-hosted mentorship scheduling system for a coach running rolling 12-week C++ mentorship cohorts (up to 20 active mentees; a 20-minute onboarding call followed by 6 biweekly sessions each).
Status: Phase 2 (optimizer preview). No external calendar mutation yet.
Architecture
- Custom app — owns program state (mentees, availability, slots, sessions, proposals, audit)
- PostgreSQL — authoritative store
- OR-Tools CP-SAT — scheduling optimizer, proposing only
- Cal.com — booking provider, behind a swappable adapter (Phase 3)
- Grafana — read-only dashboards (Phase 4)
Getting started
devenv shell # direnv does this automatically on cd; syncs deps via uv
devenv up -d # starts postgres, runs migrations, serves the app — the one command
Then open http://127.0.0.1:1337.
devenv up runs postgres and the app as a pair of managed processes: the app
process waits for postgres to report ready, runs alembic upgrade head, then
execs uvicorn with --reload. devenv shell alone starts neither — only
devenv up/devenv up -d does, and first start also runs initdb, which takes
a few seconds. Use devenv up without -d if you want to watch both processes'
logs in the foreground instead; devenv processes logs app works either way.
Individual steps remain available as scripts when you need them directly —
app:install, app:migrate, app:run, app:test — for example to run the
server without --reload under a debugger, or to re-run migrations by hand.
uv.lock is committed alongside devenv.lock, so the Python resolution is pinned
the same way the toolchain is. app:install runs a plain uv sync and will update
the lock when pyproject.toml changes — the container image built for the cluster
should use uv sync --frozen instead, so it installs exactly what was tested.
Why a local Postgres when the homelab has a cluster?
The devenv Postgres is the dev database. The deployment target is still the homelab CNPG cluster — nothing here is coupled to running locally:
app/db.pybuilds its engine fromDATABASE_URLalone, so pointing the app atpg-cluster-rw-lb(10.67.1.5) is a one-variable change.- Phase 1 iterates on schema.
db:resetis a literalDROP DATABASE, which is not something to aim at a shared, WAL-archiving, backed-up cluster. - The cluster is deliberately disposable (
cluster:down+upover staged upgrades, with the live/↔prev/ ping-pong recovery on bringup). Local dev shouldn't block on a cluster rebuild.
Keep the local major version matched to the cluster's
(ghcr.io/cloudnative-pg/postgresql:17.2) — the app only ever sees a URL and
won't warn about the drift. Bumping the major means wiping
.devenv/state/postgres so devenv re-runs initdb.
When the app deploys, the database comes from CNPG: copy the cnpg-app-grafana
pattern in homelab/k8s/helmfile-metrics.yaml (own SOPS password → the
cnpg-app-db chart creates the Database, DatabaseRole, Secret and netpols) for a
cnpg-app-mentor release, with separate mentor_app / mentor_worker /
grafana_reader roles.
Tests
app:test runs two tiers, split by whether a test needs Postgres rather than by
filename:
- Pure —
test_scheduling,test_availability,test_insights,test_calendar,test_optimizer, and the resolver half oftest_timezones. Cadence, parity, DST arithmetic and the whole CP-SAT model. No database, always runs, about two seconds for the lot. - Database-backed — everything carrying
requires_db: the*_flowmodules plustest_confirmations,test_mentee_edit,test_session_undoand the form half oftest_timezones. The full HTTP flow against real Postgres. Skips whenTEST_DATABASE_URLis unset or the server is down, so it can't touch dev data andpyteststill works withdevenv upstopped.
test_timezones.py is deliberately mixed and marks the database cases
individually — a reminder that the tier is a property of the test, not the file.
TEST_DATABASE_URL supplies connection details and a name prefix, not the
database actually used. Each run creates mentor_scheduler_test_<pid>_<token>,
migrates it, and drops it on the way out. That's because the tests TRUNCATE
between cases: two runs sharing one database deadlock — one holds row locks while
the other waits for the AccessExclusiveLock that TRUNCATE needs — which
surfaces as dozens of unrelated failures rather than anything resembling a lock
error. Concurrent runs (two terminals, an agent alongside you) are now safe.
A killed run can leave its database behind; db:test-clean drops the strays and is
safe to run while other tests are in flight.
What works today
- Mentee intake and edit, with fuzzy IANA timezone resolution and a pick list that works offline
- Coach working windows, tiled into an A-week/B-week 30-minute slot inventory
- Reserve and disable flags per slot series, with occupancy shown per slot
- Mentee availability: paintable weekly 30-minute grid (available / preferred), optionally split by A/B week, plus one-off date exceptions
- Manual standing-slot assignment, materializing six concrete sessions plus the onboarding call, gated on the mentee's availability with a deliberate override
- Intake opens a pending enrollment (an onboarding call + 6 sessions, 14-day cadence)
- Session progression (complete / missed) with one-click Undo, and enrollment graduation / withdrawal with Reinstate to undo a mistaken close
- Roster with standing slot, next session and N/20 capacity
- Calendar: week and month views with A/B parity, conflict detection, and a
/calendar/events.jsonfeed - Audit events on every mutation
- Slot-scarcity scoring and a mentee availability heatmap on the coach page
- Admissions: per-applicant earliest feasible start, what blocks each one, and which upcoming graduation would unblock them
- Solver: whole-roster CP-SAT optimization producing a reviewable proposal, with infeasibility explanations, an alternative schedule, before/after views and one-click approval that writes to Postgres only
- Confirmation prompts on destructive actions (see below)
Enforced already: slot exclusivity, the 20-mentee cap, mentee availability, an onboarding call before every programme, no releasing a slot with completed work, no disabling an occupied slot, no session materialized in the past.
Migrations run to 0005. 0004 adds the solver tables; 0005 widens the
session ordinal to admit the onboarding call at 0. Both downgrade cleanly, and
0005's downgrade deletes onboarding calls rather than renumbering them — there
is no ordinal in 1–6 for them to become that is not already taken.
The onboarding call
Every mentee has a 20-minute onboarding call before their programme starts.
It is stored on the same enrollment as ordinal 0, so ordinals 1–6 still mean
what they always did on every page, in every stored audit event and in the brief
("Exact tracking of sessions 1 through 6"). Seven meetings, six sessions;
enrollment.session_count stays 6 and "3 of 6" never counts the call.
Three rules govern it:
- Session 1 follows it by 1 to 7 days (
INTRO_LEAD_MAX_DAYS). The lower bound is not configurable: a call and the first real session hours apart is one meeting split in two. - It obeys the mentee's availability like any other meeting. Exempting it would let the solver book one at an hour they had explicitly ruled out.
- Nobody starts without one. A series with no free opening in the week before it is unusable, and the solver picks a different slot — or defers the start.
It is a 20-minute meeting that occupies a whole 30-minute slot occurrence. The entire inventory, the availability grid and every overlap check assume one 30-minute grid; a finer one for a single meeting type would buy a little capacity at the cost of that invariant. The last 10 minutes are simply not booked.
The call is not on the standing series, and that is the point. A slot series held by an incumbent is still free on every occurrence outside their twelve weeks, and a one-off call is exactly what can use it — which is why a mentee free at one clock time on both parities can be onboarded on the opposite-parity occurrence a week before they start. Pinned to a single parity, they cannot, and the solver says so.
Assigning by hand also schedules one, greedily (program.find_intro_opening).
Where nothing fits it assigns anyway and the mentee page flags the gap: refusing
would make the manual path unusable exactly when the calendar is tight, which is
when the coach most needs it.
Scheduling invariants
The cadence advances on the local calendar, not by adding 336 hours in UTC —
a series crossing a DST boundary keeps its 17:00 wall clock while its UTC time
shifts. A/B week parity is counted in whole weeks from a fixed Monday
(PARITY_EPOCH) rather than from ISO week numbers, which would flip parity in
53-week years. Local times that a DST transition makes nonexistent or ambiguous
are flagged rather than silently resolved.
Set COACH_TIMEZONE to the coach's real zone, not UTC. Coach windows and slot
series are stored as minute-of-day in that zone. Leaving it at UTC means the
coach's actual working evening drifts by an hour at each DST transition — a
23:30–02:00 UTC window is 17:30–20:00 Chicago in January but 18:30–21:00 in
July. Changing it reinterprets existing slots, so set it before building inventory
or regenerate afterwards.
A coach window that runs past midnight is entered as one window with the end
earlier than the start (23:30 → 02:00); it's stored as one row per day. The two
rows tile to exactly the same 30-minute series a single overnight window would, so
nothing is lost. One wrinkle on a Sunday→Monday wrap: weeks start on Monday, so the
two halves fall in different weeks and carry opposite A/B parity — see
split_overnight_window.
Reinstating a closed enrollment (Reinstate on the mentee page) undoes a
mistaken Withdraw or Graduate. If the old standing slot is still free it is
reopened and the cancelled future sessions go back to planned — a true undo. If
someone else has since taken the slot, the enrollment reopens as pending and the
dead future sessions are dropped so a new slot can be assigned. Sessions that
actually happened are never altered, and a mentee can never end up with two open
enrollments. For a genuine re-admission rather than an undo, open a fresh
enrollment instead.
Assignment materializes only the ordinals still missing, so an enrollment that
completed sessions 1–2 before a pause resumes at 3 rather than restarting at 1
(which would violate the per-enrollment ordinal uniqueness). actual_start_date
is preserved across a resume.
Availability is checked per concrete session, not per slot. Slots are fixed in the coach's local time while availability is recorded in the mentee's, and the offset between two zones is not constant: the US and EU change clocks three weeks apart, so a "Monday 17:00 Chicago" series sits at 23:00 London for four of its six sessions and 22:00 for the other two. A mentee can therefore be available for part of a series and not the rest.
An unpainted grid cell means hard unavailable (hard constraint 5). A mentee with no grid on file is treated as unknown rather than unavailable, so assignment isn't blocked before availability has been captured — whether the check ran is recorded on the assignment's audit event.
Mentee timezones are resolved from free text at the form edge (domain/timezones.py):
a coach types "Duba", not "Asia/Dubai". Ambiguous fragments are refused rather
than guessed, since silently picking one of six America/* zones would put a
whole availability grid hours out.
Scarcity, not popularity
A slot's scarcity score is not how many mentees want it. Each mentee spreads one unit of need across the slots that fit them, so a slot that is somebody's only option scores 1.00 from them while one of twenty options scores 0.05. Summed per slot, this ranks the slots whose loss would actually strand someone above the merely busy ones — which is the question worth asking before disabling a slot or spending one on a flexible mentee.
The heatmap is resolved against one concrete week (the coming one) rather than as a fixed offset, and says so on the page. Converting a mentee's weekly availability into coach-local time depends on the daylight-saving offsets in force that week, and a cell can legitimately land on a different weekday than it was painted on — a Tokyo mentee's Tuesday morning is the coach's Monday evening.
The optimizer
/solver runs the whole roster through CP-SAT and produces a proposal. Nothing
on the schedule moves until the coach approves it, and even then nothing reaches an
external calendar — that is Phase 3.
Two numbering schemes appear below and they are not the same. Objective N is the brief's list of nine soft constraints. Stage N is one of the six solves this implementation actually runs; several objectives share a stage.
A candidate is a plan, not a slot
The brief specifies two levels of decision variable: series_assignment and
session_assignment. For the six programme sessions only the first is free,
because the second is determined by it — choosing a standing slot fixes six
concrete timestamps, so a second layer could only disagree with the first. Each
SeriesCandidate therefore carries its materialized sessions and is judged on
those: availability, overlap and the program window are all per-session
questions.
session_assignment is a free variable for the one session that needs it: the
onboarding call (IntroCandidate). Its time does not follow from the standing
slot — it has to land in a short window before session 1 at whatever concrete
opening fits — so it is a choice of occurrence, not of series. The remaining
freedom the second level exists to provide, one-off moves and make-up sessions, is
Phase 5; the make-up objective is wired at a constant zero rather than omitted, so
adding it later changes no stage ordering and no stored shape.
Both kinds of variable are reduced to a TimeClaim before the overlap sweep.
Checking them separately would silently permit the collision most likely to happen
in practice — an onboarding call landing on top of somebody's session.
Overlap is enforced per half hour rather than per pair: one add_at_most_one
over every claim touching a bucket. Twenty mentees competing for the same half
hour is one constraint that way and a hundred and ninety written pairwise.
Starts can be deferred. build_candidates generates each (enrollment, slot)
pair at several start offsets (SOLVER_START_OFFSETS). This is not a nicety: a
series whose next occurrence is a few days out leaves no room for the call that
has to precede it, and with only one offset the solver would report the mentee
unplaceable rather than starting them a fortnight later — halving usable capacity,
since A-week and B-week series start in alternate weeks. Stage 4 keeps the wait
short.
Keeping a slot means keeping its times. For an incumbent's own slot the candidate reuses the timestamps already on the books instead of re-materializing from today. Re-materializing agrees only while the existing series sits exactly on the slot's fortnightly grid; anywhere else (a non-14-day cadence, a session deferred by hand) every incumbent would be reported as needing a move, and "minimize incumbent moves" would quietly be measuring calendar drift.
Hard vs. rankable
Three of the brief's constraints are not enforced during optimization, because they are soft objectives wearing hard-constraint clothing:
| Constraint | Treatment |
|---|---|
| Every enrollment gets six sessions (1) | objective 1 — enforcing it makes an over-subscribed roster INFEASIBLE, so the coach gets nothing instead of a schedule plus a named problem |
| Incumbents don't move | objective 2 |
| Reserve slots are protected | objective 5 |
The rest are hard: availability (5), the program window (10), the 20-mentee cap
(11), busy time (12), slot exclusivity (13), session overlap (3, 14) and the
onboarding call. Ordinals stay ordered (8) and nothing lands before admission (9)
by construction, via the start floor in _start_floor. Cadence (15) holds because
materialize_sessions produces it.
require_onboarding_call is enforced during optimization like the other hard
ones. It is an assumption literal anyway so the diagnosis ladder can report it as
the thing that bound, which on a tight calendar it often is.
Overlap is checked on concrete timestamps, not slots. Two different slot series
can collide once an enrollment's cadence_days differs from the fortnightly slot
period, because the series then drifts off its own grid.
Losing a slot is worse than changing one. Dropping an incumbent to nothing is
priced inside objective 2 at INCUMBENT_DROP_WEIGHT rather than forbidden. Made
hard, one mentee who repainted their availability into a corner would turn the
whole run infeasible.
Lexicographic, not weighted
Six solves in sequence, each freezing its optimum before the next runs. The brief's nine objectives collapse into five of them; stage 4 is an addition.
| Stage | Minimizes | Brief's objective |
|---|---|---|
| 1 | unassigned enrollments | 1 |
| 2 | incumbent moves (and drops, priced far higher) | 2 |
| 3 | changes to future sessions — a confirmed one counts 100× | 3 |
| 4 | how long anyone waits to start | — |
| 5 | make-ups (constant 0) and reserve capacity | 4, 5 |
| 6 | preferences, calendar compactness, cadence deviation, undesirable hours | 6–9 |
Stage 4 is not in the brief's list, because the brief assumed a mentee starts at the first opportunity. Once a start can be deferred — which it must be, or a series with no room for an onboarding call ahead of it makes the mentee unplaceable rather than merely later — something has to say waiting is a cost. It sits above reserve and preferences: a coach would spend protected capacity, or a less-loved hour, to start someone a fortnight sooner.
No single weighted sum can do this. Twenty mentees each gaining a marginally nicer hour will outweigh dragging one incumbent out of the slot they have held for two months, and no choice of weights fixes it — the number of mentees isn't fixed. Weights only ever compare terms inside one stage, where the trade-off is between comparable things.
Fragmentation (the brief's objective 7, inside stage 6) is measured in bookable positions left empty between the day's first and last booking, not clock minutes. A coach who works 10:00–11:00 and 17:00–19:00 has six hours between those windows that no scheduling can remove; counting it would rank every schedule identically badly. A-week and B-week series at the same clock time are different days here — they never occur in the same week.
Why the run is infeasible, in words
Every bendable constraint sits behind a CP-SAT assumption literal. When no
schedule places everybody, the ladder in explanations.py relaxes them one at a
time — reserve, then the program window, then the onboarding call, then incumbent
moves, then availability, then the cap — and reports the first rung that helps,
which is what distinguishes "feasible only by using reserve capacity" from
"feasible only by moving an incumbent".
The ladder is cumulative, so reaching a rung proves the accumulated set is
sufficient, not that every member is needed. It therefore asks once more with only
the tipping constraint relaxed, and reports one concession where one will do. The
wording deliberately echoes admissions._blocker; a solver that phrases the same
situation differently reads as a second, disagreeing opinion.
Approving
Approval applies the solver's own timestamps through
program.apply_proposed_schedule. Re-deriving them would be a second opinion
computed against a roster that has moved on since the page was rendered, and the
coach would get a schedule they never saw. The onboarding call is applied in the
same call as the programme it introduces, so the two land in one transaction — an
enrollment with sessions but no call, or the reverse, is a state the coach would
have to repair by hand.
Every Phase 1 guard still runs at apply time — the slot must still be free, the cap must still hold, and a held or locked session is never overwritten — precisely because the state may have changed under the proposal. Slots are vacated in a first pass and taken in a second, or a proposal where two mentees swap slots would fail on whichever was applied first.
Only one proposal is live at a time; running the solver marks any earlier pending
run superseded. Two live proposals computed against different states would each
look applicable, and applying the second after the first would write a schedule
nobody reviewed.
Moving an incumbent mid-program is deliberately not routed through
release_slot, which refuses once work has happened and deletes every session.
Releasing means giving up; moving keeps the enrollment active and re-times what
remains. vacate_slot is the half that closes an assignment without touching
sessions.
How long it takes, and what "feasible" means
SOLVER_TIME_LIMIT_SECONDS is per stage, so the worst case is six times it.
Measured on a 20-mentee roster:
| Inventory | Variables | Result |
|---|---|---|
| 168 slots (roomy) | 6,480 | 503 ms, optimal |
| 120 slots | 4,560 | hits the budget on the last stages |
| 80 slots (about half utilised) | 3,040 | hits the budget on the last stages |
Difficulty tracks contention, not size — the largest model above is also the fastest. Packing forty occupancies into eighty half hours with narrow mentee availability is where proving the last tie-break optimal gets hard.
A stage that runs out of time keeps the schedule found so far and marks the run
feasible rather than optimal; the proposal page says which stage gave up. That
schedule still satisfies every hard constraint and every optimum frozen before it
— what is unproven is only the tie-breaking. Raising the budget buys the proof,
not a materially better schedule.
A late stage finding nothing must not discard the run. Earlier stages have
already produced a valid schedule; throwing it away hands the coach nothing after
a minute of work. objectives.solve snapshots the objective vector after every
successful stage for exactly this reason — after a stage that finds nothing, the
solver holds no solution left to read.
Runs are kept, with their input
solver_run.input_snapshot is a full copy of the problem, not a set of foreign
keys. Slots get disabled, mentees repaint availability and enrollments graduate, so
a run reconstructed from live rows six weeks later would explain a schedule nobody
ever saw. /solver/runs/{id}.json serves the whole thing.
Confirmation prompts
Destructive controls carry a data-confirm attribute; static/confirm.js binds one
document-level listener that prompts before the default action runs. The message
lives in the markup next to the action so it can state the real cost — how many
sessions get cancelled, which slot is given up, and whether an undo exists.
Two rules keep the prompts meaningful:
- Reversible actions don't prompt. Complete/Missed have a one-click Undo, and Reinstate restores a closed enrollment. Prompting routine reversible work trains people to dismiss dialogs, which weakens the prompts that matter.
- Only the destructive direction prompts. Disabling a slot asks; re-enabling doesn't. Clearing the availability grid says plainly that nothing is lost until Save.
Saving an empty availability grid is confirmed from availability.js instead, since
it depends on what was just painted rather than on the rendered page.
tests/test_confirmations.py fails if any control whose label implies data loss
lacks a prompt, so a new destructive button can't ship unguarded.
Traps
Things that cost real time to find, and that look like something else when they bite.
OR-Tools needs two system libraries that nix does not put on the loader path.
The ortools wheel is manylinux and links against the distro's libstdc++ and
libz. devenv.nix prepends them via withWheelLibs, which every script and
process that runs Python includes — enterShell alone does not cover devenv
scripts or devenv up processes, so adding a new script means adding the
preamble. Without it the failure surfaces one layer down, as numpy reporting
libz.so.1: cannot open shared object file, which looks like a broken numpy
install. The container image needs both libraries in its base for the same reason.
only_enforce_if([]) is unconditional, not vacuous. An empty enforcement-literal
list means the constraint always applies. Passing one where you meant "don't
enforce this" silently hard-codes the very thing you were relaxing — and the
symptom is a model that gets stricter the more you relax, which reads like a
solver bug. model.Assume returns IntVar | None and every consumer guards on
is None for this reason; never re-introduce a list.
SOLVER_START_OFFSETS must not be 1. With a single offset, a series whose
next occurrence is a few days away has no later alternative, so a mentee with no
room for an onboarding call before it is reported unplaceable instead of
starting a fortnight on. Because A-week and B-week series start in alternate
weeks, that halves usable capacity — and it looks like an availability problem.
Availability applies to both variable kinds. _availability and
_reserve_protection iterate data.candidates and data.intros. Add a
constraint that only walks one list and the onboarding call quietly escapes it —
this shipped broken once and the tests now pin it.
sessions_for/min(ordinal) is the onboarding call, not session 1. Anything
that wants "the first session" must filter ordinal != 0 or ask for ordinal 1.
Several tests were silently completing the intro before this was caught.
Per-parity availability needs split=1. Posting a grid with A:/B: prefixes
but split=0 discards the parity, and the mentee ends up available on both weeks.
Easy to get wrong in a test and it makes the case you were building disappear.
A brand-new SQLAlchemy row has None, not the column default. Column defaults
are applied by the INSERT, so row.schedule_version += 1 on a row added moments
ago raises TypeError. Use (x or 0) + 1.
Route order decides /runs/{id}.json. Starlette matches in declaration order
and a path parameter happily swallows a .json suffix, so the JSON route must be
declared before the HTML one or it 422s on a UUID ending in .json.
Count and list must come from the same predicate. SolverInput.schedulable
means "owed a schedule", not "has candidates" — a mentee for whom no slot works
has no candidates and is exactly the person to name. Deriving the unassigned
count and the unassigned list from different predicates made the page report
a problem it then refused to explain.
Session rows are created in two places. program.assign_slot (manual) and
program.apply_proposed_schedule (approved proposal). A change that touches how
sessions come into existence — external booking ids, outbox events, a new column
— has to cover both, and hooking only the solver path leaves every hand-assigned
mentee silently unhandled.
The dev database is not disposable in the way the cluster is. db:reset is a
literal DROP DATABASE. Running the solver against dev data is safe — it only
writes solver_run/change_proposal rows — but approving a proposal rewrites
real schedule state.
Layout
app/
api/ FastAPI routers
domain/ SQLAlchemy models + domain logic
scheduling.py pure cadence/parity/slot arithmetic + the pinned-session rule
availability.py pure grid <-> window translation + slot-fits-mentee check
insights.py pure scarcity scoring + heatmap projection
calendar.py pure week/month bounds + overlap detection
timezones.py pure IANA pick list + fuzzy resolution
program.py enrollment + assignment operations, hard-constraint checks
audit.py audit-event recording
optimizer/ CP-SAT scheduling optimizer
candidates.py pure: what could be scheduled, with every cost precomputed
model.py the model: hard constraints + assumption literals
objectives.py the lexicographic solve-and-fix sequence
explanations.py why an infeasible or disruptive run came out that way
runner.py the only file here that touches the database
web/ Jinja templates + static assets (HTMX)
migrations/ Alembic
tests/
dashboards/ Grafana JSON (Phase 4)
deploy/ Container image build context; Helm chart lives in the
homelab repo under k8s/charts/mentor-scheduler
Everything in optimizer/ above runner.py is free of both the database and
OR-Tools' solver calls, which is why tests/test_optimizer.py runs in under two
seconds with devenv up stopped.
Phases
- 1 — Program system of record (schema, intake, roster, manual assignment)
- 2 — OR-Tools optimizer preview (proposals only, no calendar writes)
- 3 — Cal.com integration (booking provider, webhooks, outbox)
- 4 — Dashboards + alerts
- 5 — Advanced optimization
Phases 1 and 2 are complete. Every admin page the brief calls for exists, and the optimizer generates schedules the coach reviews and approves.
Where to pick up
Phase 3: the Cal.com adapter. Three seams are already cut for it, and one new requirement falls out of the onboarding call.
-
Busy times —
SolverInput.busyis a list ofBusyIntervalthatmodel._respect_busy_timealready enforces (hard constraint 12).runner.build_inputpasses[]. Filling it from the provider'sget_busy_timesis a data change, not a model change. -
The outbox —
runner.apply_runupdates PostgreSQL and stops. The brief's workflow insertsoutbox_eventrows in the same transaction, which is whyapply_runstages without committing: the caller owns the transaction boundary, so the outbox insert lands atomically with the schedule change. -
The external id —
session.external_booking_uidexists and is only ever read (the calendar's "externally booked" flag). Nothing writes it yet.Note there are two places a session row is born, not one:
program.assign_slotfor manual assignment andprogram.apply_proposed_schedulefor an approved proposal. Whatever emits outbox events has to cover both, or hooking only the solver path would leave every hand-assigned mentee missing from the external calendar. Funnelling the manual path throughapply_proposed_scheduleis the tidier fix and is a reasonable first move in Phase 3.
Two event types, not one. The brief specifies a hidden 30-minute coaching
event type. There are now two durations: the onboarding call is 20 minutes
(INTRO_SESSION_MINUTES) and a programme session is 30. scheduling.is_intro
distinguishes them from the ordinal alone, and the stored scheduled_end is
already correct for both — but a single Cal.com event type would send the wrong
invitation length for one of them.
Then: the BookingProvider protocol, CalDiyBookingProvider, webhook ingestion
and the reconciliation job.
Smaller things worth doing
- Enrollments that predate the onboarding call. Migration 0005 backfills nothing, so anything assigned before it has no ordinal-0 row. The mentee page flags this and the next solver run offers one — but on a tight calendar that can force an incumbent move, because the call has to precede session 1. Worth a look before running the solver over a full roster for the first time.
- The solver page has no progress indication. A tight roster can take the better part of a minute and the form just hangs. Either stream it or run it as a background job.
explanations.diagnosere-solves up to seven times on an unplaceable roster: once per rung of the six-rung ladder, plus one confirmation that the tipping constraint alone was enough. Each is feasibility-only and cheap, but it is the obvious thing to cache if diagnosis ever shows up in a profile.- Alternatives cost a full lexicographic sequence each (
SOLVER_ALTERNATIVES). Two would roughly double the run time.
Known gaps, all deliberate:
- Intake doesn't capture minimum notice for schedule changes, or whether times outside preferred availability may be proposed. The optimizer treats non-preferred hours as a soft cost (objective 6) rather than as forbidden, so the second setting would be a per-mentee override of that; neither has a consumer yet.
- No make-up sessions.
plan_candidatematerializes an even cadence and nothing else, so the brief's objective 4 is a constant zero inside stage 5 andChangeType.makeupnever occurs. Automatic make-up scheduling is Phase 5 by the brief's own ordering; the term is wired so adding it changes no stage ordering and no stored shape. - Sequential slot reuse isn't modelled. Two enrollments whose programs don't overlap in time could in principle share one slot series. Phase 1 models a standing slot as exclusively held, and a proposal the coach cannot then apply through the normal path would be worse than the lost capacity.
- The onboarding call cannot be rescheduled on its own. It moves only as part of a whole solver run, or not at all. A one-off "move this call to Thursday" needs the same session-level freedom Phase 5 brings for make-ups.
is_makeupis never set, for the same reason. The column and the calendar flag exist; nothing writes them.outbox_eventhas no table yet — it belongs to the phase that first uses it.- Admin pages have no authentication. The brief puts that at the ingress.
deploy/anddashboards/are empty; the Helm chart lands in the homelab repo.
Deviations from the brief
No FullCalendar
The brief specifies FullCalendar Standard. The calendar is instead server-rendered
as a plain table, for the same reason the availability grid is: the app stays
dependency-free and works without internet access, which matters for something
whose first design principle is being fully self-hosted. /calendar/events.json
already serves events in FullCalendar's feed shape, so dropping the library in
later (for drag-to-reschedule, say) needs no restructuring.
Seven meetings, not six
The brief describes six sessions per mentee. Every mentee now also has a 20-minute onboarding call before the programme starts — see The onboarding call. It is stored as ordinal 0 on the same enrollment, so the brief's "exact tracking of sessions 1 through 6" is unchanged.
A sixth solve
The brief's five-solve example gains one stage, "waiting to start", because a start can now be deferred and something has to price the wait. See Lexicographic, not weighted.

