No description
  • Python 84.7%
  • HTML 10.3%
  • CSS 2%
  • JavaScript 1.8%
  • Nix 1.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
xbazzi 008f91190f feat: CP-SAT optimizer preview and onboarding call (Phase 2)
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.
2026-07-28 18:58:30 -05:00
.vscode feat: CP-SAT optimizer preview and onboarding call (Phase 2) 2026-07-28 18:58:30 -05:00
app feat: CP-SAT optimizer preview and onboarding call (Phase 2) 2026-07-28 18:58:30 -05:00
img feat: add images 2026-07-27 23:05:40 -05:00
migrations feat: CP-SAT optimizer preview and onboarding call (Phase 2) 2026-07-28 18:58:30 -05:00
tests feat: CP-SAT optimizer preview and onboarding call (Phase 2) 2026-07-28 18:58:30 -05:00
.env.example feat: CP-SAT optimizer preview and onboarding call (Phase 2) 2026-07-28 18:58:30 -05:00
.envrc feat: mentorship scheduling system of record (Phase 1) 2026-07-27 23:00:41 -05:00
.gitignore feat: mentorship scheduling system of record (Phase 1) 2026-07-27 23:00:41 -05:00
alembic.ini feat: mentorship scheduling system of record (Phase 1) 2026-07-27 23:00:41 -05:00
coaching-problem.md feat: mentorship scheduling system of record (Phase 1) 2026-07-27 23:00:41 -05:00
devenv.lock feat: mentorship scheduling system of record (Phase 1) 2026-07-27 23:00:41 -05:00
devenv.nix feat: CP-SAT optimizer preview and onboarding call (Phase 2) 2026-07-28 18:58:30 -05:00
devenv.yaml feat: mentorship scheduling system of record (Phase 1) 2026-07-27 23:00:41 -05:00
pyproject.toml feat: CP-SAT optimizer preview and onboarding call (Phase 2) 2026-07-28 18:58:30 -05:00
README.md feat: CP-SAT optimizer preview and onboarding call (Phase 2) 2026-07-28 18:58:30 -05:00
uv.lock feat: CP-SAT optimizer preview and onboarding call (Phase 2) 2026-07-28 18:58:30 -05:00

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.py builds its engine from DATABASE_URL alone, so pointing the app at pg-cluster-rw-lb (10.67.1.5) is a one-variable change.
  • Phase 1 iterates on schema. db:reset is a literal DROP DATABASE, which is not something to aim at a shared, WAL-archiving, backed-up cluster.
  • The cluster is deliberately disposable (cluster:down + up over 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:

  • Puretest_scheduling, test_availability, test_insights, test_calendar, test_optimizer, and the resolver half of test_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 *_flow modules plus test_confirmations, test_mentee_edit, test_session_undo and the form half of test_timezones. The full HTTP flow against real Postgres. Skips when TEST_DATABASE_URL is unset or the server is down, so it can't touch dev data and pytest still works with devenv up stopped.

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.json feed
  • 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 16 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 16 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:3002:00 UTC window is 17:3020:00 Chicago in January but 18:3021: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 12 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 69

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:0011:00 and 17:0019: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.

  1. Busy timesSolverInput.busy is a list of BusyInterval that model._respect_busy_time already enforces (hard constraint 12). runner.build_input passes []. Filling it from the provider's get_busy_times is a data change, not a model change.

  2. The outboxrunner.apply_run updates PostgreSQL and stops. The brief's workflow inserts outbox_event rows in the same transaction, which is why apply_run stages without committing: the caller owns the transaction boundary, so the outbox insert lands atomically with the schedule change.

  3. The external idsession.external_booking_uid exists 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_slot for manual assignment and program.apply_proposed_schedule for 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 through apply_proposed_schedule is 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.diagnose re-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_candidate materializes an even cadence and nothing else, so the brief's objective 4 is a constant zero inside stage 5 and ChangeType.makeup never 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_makeup is never set, for the same reason. The column and the calendar flag exist; nothing writes them.
  • outbox_event has 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/ and dashboards/ 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.