Timetables — Manual Management, Diagnostics & Automatic Generation¶
Two producers of ScheduledLesson rows: (1) an admin placing lessons manually via the CRUD surface; (2) POST /timetables/generate calling out to a CP-SAT solver that produces a full DRAFT in one shot. Both producers feed the same diagnostics engine; the same publish gate applies to both.
Specs: 2026-06-08-timetable-manual-management-design.md (base) + 2026-06-11-timetable-manual-management-iteration-2-design.md (window-aware engine + lifecycle) + 2026-06-12-timetable-generation-design.md (automatic generation). Builds on chapter 14 (homerooms / subject groups) and the timetable-templates/ module (week templates).
1. Mental model¶
Two models (prisma/schema.prisma):
Timetable— one per build attempt, scoped to(tenant, academicYear). LifecycleTimetableStatus { DRAFT, PUBLISHED, ARCHIVED }. Many DRAFTs may coexist; at most one PUBLISHED per(tenant, AY)— enforced by a partial-unique index (timetables_tenant_ay_published_unique, raw SQL in the migration; see chapter 12).ScheduledLesson— one placed lesson: aSubjectGroupoccupying a coordinate(weekday, periodOrdinalPosition)of its grade's effective week template, in aRoom. The coordinate is resolved to wall-clock at read time (subjectGroup → grade → effective WeekTemplate → day[weekday] → PERIOD slots). There is notimeSlotIdFK — slots are addressed by ordinal position, so a template edit re-resolves every lesson.@@unique([timetableId, subjectGroupId, weekday, periodOrdinalPosition])prevents placing the same group twice in one slot.
periodOrdinalPosition is the TimeSlot.ordinalPosition of a PERIOD slot in the group's effective day template (INTERVAL/LUNCH slots are never teaching coordinates).
2. Structural floor vs soft diagnostics¶
The single most important split:
- Structural floor — a small set of hard invariants enforced at write time; a violation rejects the mutation. These are: cross-tenant FK integrity (room/subject-group must be in-tenant), coordinate uniqueness (
409 SCHEDULED_LESSON_COORDINATE_CONFLICT), and "the coordinate is an actual PERIOD slot of the group's effective template" (422 SCHEDULED_LESSON_INVALID_SLOT). The floor keeps the data coherent. - Soft diagnostics — everything pedagogical (conflicts, capacity, hour budgets, gaps, …). These never block a DRAFT edit. They are computed and returned, and they gate publish, but authoring stays frictionless.
This is why you can build a half-finished, conflict-ridden DRAFT freely, and only the publish gate forces resolution.
3. The diagnostics engine¶
A pure function computeDiagnostics(snapshot): Violation[] in src/timetables/timetables.diagnostics.ts. It takes a plain in-memory DiagnosticsSnapshot (no Prisma types leak in) built by buildDiagnosticsSnapshot(...) in timetables.queries.ts, which resolves wall-clock + PERIOD-rank per lesson, rosters, teaching windows, subject-room sets, homeroom calendar spans, and option-block maxSelections.
A Violation is { category, severity, params } — i18n codes + a denormalized params payload, never BE-rendered prose. The FE renders the message. canPublish(violations) is true iff no ERROR-tier violation exists.
Catalogue — 13 ERROR + 3 WARNING (window-aware)¶
| Category | Tier | Fires when |
|---|---|---|
TEACHER_CONFLICT |
ERROR | one teacher in two time-overlapping lessons |
ROOM_CONFLICT |
ERROR | one room in two time-overlapping lessons |
CLASS_CONFLICT |
ERROR | a homeroom has two distinct occupancies in one cell |
STUDENT_CONFLICT |
ERROR | a student double-booked (suppressed if already a CLASS_CONFLICT) |
OPTION_BLOCK_SYNC |
ERROR | a maxSelections = 1 block's children are not all co-slotted (pick-one ⇒ must coincide) |
OPTION_BLOCK_CLASH |
ERROR | a maxSelections > 1 block has two different children overlapping in time (a co-pick would clash) |
ROOM_CAPACITY |
ERROR | lesson roster exceeds Room.maximumCapacity |
ROOM_NOT_IN_SUBJECT_SET |
ERROR | a subject with a non-empty CurriculumSubjectRoom set is placed outside it (gym/lab/music) |
SUBJECT_GROUP_NOT_IN_BASE_ROOM |
ERROR | a SubjectGroup with a baseRoomId has a lesson placed in any other room |
TEACHER_AVAILABILITY |
ERROR | lesson on a weekday outside the teacher's daysOn |
HOUR_BUDGET |
ERROR | placed-lesson count ≠ the (subject, grade) weeklyHours cell |
HOMEROOM_GAP |
ERROR | a PERIOD slot of the homeroom's template is uncovered across the department-calendar span |
NO_EFFECTIVE_TEMPLATE |
ERROR | a homeroom owning lessons has no resolvable week template |
TEACHER_DAILY_CAP |
WARNING | a teacher exceeds 4 lessons on one day |
SAME_DAY_CONTIGUITY |
WARNING | a group's same-day placements aren't consecutive periods |
SAME_ROOM_DOUBLE |
WARNING | a consecutive "double" of one group switches room mid-block |
Teaching windows (iteration-2)¶
A subject not taught all year (CurriculumSubjectHours.taughtAllYear = false, with teachingStartDate/teachingEndDate, PYP units of inquiry) carries a derived window per lesson — never stored. Two lessons with disjoint windows do not conflict even at the same coordinate (windowsOverlap short-circuits the four overlap checks), so two half-year subjects legitimately tile one slot. HOMEROOM_GAP is correspondingly a calendar-interval coverage check: the union of a slot's placed windows must equal the homeroom's department-calendar span (subtractIntervals, day granularity); a null window covers the full span.
4. Always-on envelope + live-edit validation¶
Every lesson mutation (POST/PATCH/DELETE …/lessons) returns ScheduledLessonMutationResultDto = { lesson?, diagnostics } — the affected lesson (omitted on delete) plus the whole-timetable diagnostics recomputed after the edit. The FE never has to issue a separate diagnostics request after an edit.
mutateLessonAndDiagnose branches on status:
- DRAFT — apply the write unconditionally, then recompute (non-blocking authoring).
- PUBLISHED — apply the write and recompute inside one transaction; if the edit introduces any
ERROR, roll back and throw422 TIMETABLE_EDIT_WOULD_VIOLATE. A published timetable can never silently become invalid. (Notifying impacted users on a live change is deferred.)
GET …/diagnostics runs the same computeDiagnostics for an on-demand report.
5. Lifecycle & publish gate¶
PUT …/status toggles DRAFT ↔ PUBLISHED only (ARCHIVED is rejected at the DTO — see below). Publishing requires, in order:
- The timetable is not
ARCHIVED(409 TIMETABLE_READ_ONLY). - The academic year is
ACTIVE(422 TIMETABLE_PUBLISH_AY_NOT_ACTIVE) — draft prep on a non-active year is fine; only publish is gated. - Zero
ERRORviolations (422 TIMETABLE_PUBLISH_BLOCKED). - No other PUBLISHED timetable in the year (
409 TIMETABLE_PUBLISH_CONFLICT— pre-checked, with the partial-unique index as backstop).
ARCHIVED is set only by the AY-archival cascade (no client transition — the status DTO uses @IsIn([DRAFT, PUBLISHED])). An ARCHIVED timetable is read-only: every edit and transition throws 409 TIMETABLE_READ_ONLY. The AY-archival hook is a no-op until AY archiving lands.
6. Read views¶
GET …/lessons?view=<v>&targetId=<id> returns TimetableLessonsViewDto { target, slotGrids, lessons } for one of six views: grade | homeroom | subject-group | room | teacher | student. slotGrids is the empty slot template(s) the FE renders the grid from — for the single-grade views (grade/homeroom/subject-group) it's resolved from the target's grade even with zero placed lessons; for the multi-grade views (room/teacher/student) it's the union of the returned lessons' grade templates.
7. RBAC¶
ADMIN-only in v1. One entity EntityKey.TIMETABLES with a single descriptor-only configuration scope (flat DTOs → timetables is in FLAT_DTO_ENTITIES, no FIELD_MAPPINGS row) and four actions create / delete / publish / generate. TimetablesPolicy has a single admin pass-through branch (every non-admin is fail-closed). The ADMIN role auto-grants via ALL_WRITE/ALL; no other role is granted anything. Introducing publish and generate also required adding them to the ACTION_NAMES union in src/permissions/interfaces/decorators.interfaces.ts (the TS type @RequireAction validates against).
8. Automatic generation¶
Pipeline¶
POST /timetables/generate (@RequireAction(TIMETABLES, 'generate'), admin-only) runs:
- Snapshot —
buildGenerationSnapshot(prisma, tenantId, academicYearId)assembles segments, groups (with compatible rooms + windows), homerooms, option-block cohorts, teacher availability, and pre-computed slot-overlap + roster-clash pairs. - Pre-checks —
runPreChecks(snapshot)runs 7 pure checks (see table below). Any violation →422 TIMETABLE_GENERATION_PRECHECK_FAILED(data.violations,params.outcome = 'PRECHECK_FAILED'). Solver is never called. - Solver call —
SolverPort.generate(toSolverRequest(snapshot, config)). If the solver service is unreachable or returns a non-2xx →502 TIMETABLE_GENERATION_SOLVER_UNREACHABLE. - INFEASIBLE →
422 TIMETABLE_GENERATION_INFEASIBLE(data.{ violations, skippedSubjectGroups, solveMetadata },params.outcome = 'INFEASIBLE'). Violations are enriched from the solver's unsat-core with{ id, displayName }refs. - TIMEOUT →
422 TIMETABLE_GENERATION_TIMEOUT(data.{ violations: [], skippedSubjectGroups, solveMetadata },params.outcome = 'TIMEOUT'). - FEASIBLE → ingest into a new
DRAFTin one$transaction(insertTimetable+scheduledLesson.createMany), then immediately runcomputeDiagnosticson the persisted draft. If anyERRORviolations: roll back the entire transaction and throw500(solver/diagnostics-engine drift is a loud bug, not a user-recoverable error). On zero ERRORs: return201{ timetable, diagnostics, skippedSubjectGroups, solveMetadata }.
Skipped subject groups — groups with null or fractional weeklyHours are excluded from the snapshot before the solver sees them (reason: HOURS_NULL | HOURS_FRACTIONAL). They appear in skippedSubjectGroups in both success and failure responses and can be placed manually afterwards.
Governing principle¶
The diagnostics engine is the single authority on validity. The solver's 7 hard-constraint families (HOUR_BUDGET, HOMEROOM_COVERAGE, TEACHER_CONFLICT, ROOM_CONFLICT, STUDENT_CONFLICT, OPTION_BLOCK_SYNC, OPTION_BLOCK_CLASH) correspond to the engine's ERROR checks. The 3 WARNING checks (TEACHER_DAILY_CAP, SAME_DAY_CONTIGUITY, SAME_ROOM_DOUBLE) and quality preferences are mirrored in the soft objective. The post-ingest diagnostics pass is the safety net: any solver/BE constraint drift is caught before an admin ever sees the draft.
One ERROR check has no named solver family — SUBJECT_GROUP_NOT_IN_BASE_ROOM. A SubjectGroup with a baseRoomId is hard-forced structurally: toSolverRequest collapses that group's compatibleRoomIds to [baseRoomId], so the existing y-coupling (sum_r y == x) + ROOM_CONFLICT pin every lesson there — no new assumption family is needed. A generated FEASIBLE draft therefore can never trip the check (the group's room domain is the single base room); the engine mirror exists so a manual edit that moves a base-room lesson elsewhere still blocks publish. An incompatible base room (capacity / subject-room-set) is caught earlier by the SUBJECT_GROUP_BASE_ROOM_INCOMPATIBLE pre-check (below).
Segment model¶
The year is partitioned at teaching-window + department-calendar boundaries into day-granularity elementary segments (computeSegments in generation.queries.ts). Solver variables are weekly; conflict constraints are stated once per segment (over the groups active in that segment), so disjoint-window groups never conflict and overlapping-window groups always do. Homeroom coverage is checked per segment. See §4 of the spec for the full formalization.
Option-block cohort scoping¶
The generation snapshot and the diagnostics engine key option-block cohorts identically:
maxSelections |
Cohort key | Constraint |
|---|---|---|
= 1 |
(blockId, homeroomId) |
SYNC — all children must be co-slotted (one b variable per cohort) |
> 1 |
(blockId, gradeId) |
CLASH — no two children may overlap in time |
This keying was fixed in the diagnostics engine's checkOptionBlockSync / checkOptionBlockClash as part of the generation work to align with how the solver reasons about blocks.
Pre-checks¶
Pure checks in generation.prechecks.ts run before the solver (8 categories). Each fires as ERROR severity.
| Category | Fires when |
|---|---|
SUBJECT_HOURS_INVALID |
Any group has null, fractional, or zero weeklyHours (after skipped groups are excluded — these are groups that made it into the snapshot despite bad hours) |
NO_EFFECTIVE_TEMPLATE |
A homeroom with schedulable groups has no resolved week template; or a standalone group has none |
ROOM_UNPLACEABLE |
A group has no compatible rooms (after capacity + subject-room-set filtering) |
SUBJECT_GROUP_BASE_ROOM_INCOMPATIBLE |
A group's baseRoomId is not in its (capacity + subject-room-set) compatible set — the strict base-room rule; blocks generation before the room-domain collapse |
TEACHER_CAPACITY |
A teacher's assigned weekly hours exceed their available slots (sum over daysOn, greedy non-overlapping intervals across all relevant templates) in any segment |
OPTION_BLOCK_TEACHER_DUPLICATE |
A maxSelections=1 cohort has one teacher appearing in two or more different child groups (co-placement would create a teacher conflict) |
OPTION_BLOCK_CHILD_MISMATCH |
A maxSelections=1 cohort's children have mismatched weeklyHours or teaching windows |
HOMEROOM_HOUR_IDENTITY |
Sum of curriculum hours for a homeroom ≠ its template's weekly PERIOD-slot count, in any segment it spans |
The solver service¶
solver/ (repo root) — a stateless FastAPI + OR-Tools CP-SAT Python microservice. Endpoints: GET /health (no auth), POST /v1/generate (bearer secret, TIMETABLE_SOLVER_SECRET). One-shot: receives a generation snapshot, returns { status: FEASIBLE|INFEASIBLE|TIMEOUT, assignments?, violations?, solveMetadata }.
Budget split: the timeLimitSeconds config (default 300 s, from TIMETABLE_SOLVER_TIME_LIMIT_SECONDS) is split 75% to the main solve, ~25% reserved for deletion-based unsat-core minimization on the INFEASIBLE branch. Fixed seed 42 for reproducibility.
Variables: x[(group, slot)] (placement BoolVar), y[(group, room, slot)] (room assignment BoolVar — for a base-room SG the room domain is the single baseRoomId, so the coupling hard-pins every lesson there), b[(cohortKey, slot)] (block-unit BoolVar for maxSelections=1 cohorts). All 7 hard families are enforced via assumption literals so an infeasibility core maps back to {category, entityRefs} pairs. The BE enriches entity ids with { displayName } before returning to the FE.
Soft objective (12 terms): 3 warning-mirror terms (teacherDailyCap, sameDayContiguity, sameRoomDouble) + 9 quality terms (teacherDaySpan, doublesPreference, departmentRoomPreference, homeroomRoomStability, subjectGroupRoomStability, evenWeeklyDistribution, teacherPresence, soloIsolatedPeriods, homeroomBaseRoomPreference). Weights are passed per-request from the BE (DEFAULT_SOFT_WEIGHTS in solver.port.ts); zero weight skips the term entirely. Room-preference semantics: departmentRoomPreference penalizes only rooms of a different department (a NULL-department "general-purpose" room is favored, not penalized); subjectGroupRoomStability minimizes the distinct rooms a single SG uses across its lessons (complements homeroomRoomStability, which minimizes rooms across a homeroom's whole mandatory set). Both skip base-room-pinned SGs (single forced room).
The BE talks to the solver via SolverPort (DI token SOLVER_PORT): HttpSolverTransport in production (reads env at construction), FakeSolverTransport in tests (mailer-style seam — inject via the DI token, set .nextResponse before each test).
Env vars¶
TIMETABLE_SOLVER_URL # URL of the solver service (e.g. http://localhost:8000)
TIMETABLE_SOLVER_SECRET # Shared bearer secret; solver fails closed (401) if unset
TIMETABLE_SOLVER_TIME_LIMIT_SECONDS # Total budget in seconds (default: 300); applies to both the main solve and the infeasibility-core pass
9. Key files¶
src/timetables/timetables.diagnostics.ts # pure 15-check window-aware engine (the heart)
src/timetables/timetables.queries.ts # CRUD + buildDiagnosticsSnapshot + effective-template/window/response loaders
src/timetables/timetables.service.ts # lifecycle, structural floor, envelope, live-edit, publish gate, views
src/timetables/timetables.controller.ts # 13 routes (incl. POST generate)
src/timetables/timetables.policy.ts # admin-only record access
src/timetables/dto/ # timetable / lesson / view / diagnostics / mutation-envelope DTOs
src/timetables/generation/generation.queries.ts # buildGenerationSnapshot: segments, groups, cohorts, clash pairs
src/timetables/generation/generation.prechecks.ts # runPreChecks: 7 pure pre-check categories
src/timetables/generation/solver.port.ts # SolverPort interface + HttpSolverTransport + FakeSolverTransport + DEFAULT_SOFT_WEIGHTS + toSolverRequest
src/timetables/generation/generation.service.ts # pipeline: snapshot → pre-checks → solver → ingest → verify
src/timetables/generation/dto/ # GenerateTimetableDto, GenerationResultDto, SolveMetadataDto, SkippedSubjectGroupDto
solver/app/main.py # FastAPI app: /health + /v1/generate (bearer auth, body-size guard)
solver/app/model.py # CP-SAT model builder: x/y/b vars + 7 hard families via assumption literals
solver/app/objective.py # 11-term soft objective (3 warning-mirror + 8 quality)
solver/app/solve.py # budget split, FEASIBLE extraction, INFEASIBLE unsat-core minimization
solver/app/schemas.py # Pydantic request/response schemas (GenerateRequest, GenerateResponse, …)
To add a diagnostics check: add a check* function to timetables.diagnostics.ts, thread any new snapshot field through buildDiagnosticsSnapshot in timetables.queries.ts, and unit-test it in timetables.diagnostics.spec.ts (the engine is fully unit-covered — every category has a test). To change the publish gate: TimetablesService.setStatus. To add a view: viewWhere + resolveTargetGradeId + resolveViewTarget in the service. To add a pre-check: add a check* function to generation.prechecks.ts and call it from runPreChecks. To change solver weights: DEFAULT_SOFT_WEIGHTS in solver.port.ts.