Skip to content

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). Lifecycle TimetableStatus { 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: a SubjectGroup occupying a coordinate (weekday, periodOrdinalPosition) of its grade's effective week template, in a Room. The coordinate is resolved to wall-clock at read time (subjectGroup → grade → effective WeekTemplate → day[weekday] → PERIOD slots). There is no timeSlotId FK — 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 throw 422 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:

  1. The timetable is not ARCHIVED (409 TIMETABLE_READ_ONLY).
  2. The academic year is ACTIVE (422 TIMETABLE_PUBLISH_AY_NOT_ACTIVE) — draft prep on a non-active year is fine; only publish is gated.
  3. Zero ERROR violations (422 TIMETABLE_PUBLISH_BLOCKED).
  4. 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:

  1. SnapshotbuildGenerationSnapshot(prisma, tenantId, academicYearId) assembles segments, groups (with compatible rooms + windows), homerooms, option-block cohorts, teacher availability, and pre-computed slot-overlap + roster-clash pairs.
  2. Pre-checksrunPreChecks(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.
  3. Solver callSolverPort.generate(toSolverRequest(snapshot, config)). If the solver service is unreachable or returns a non-2xx → 502 TIMETABLE_GENERATION_SOLVER_UNREACHABLE.
  4. INFEASIBLE422 TIMETABLE_GENERATION_INFEASIBLE (data.{ violations, skippedSubjectGroups, solveMetadata }, params.outcome = 'INFEASIBLE'). Violations are enriched from the solver's unsat-core with { id, displayName } refs.
  5. TIMEOUT422 TIMETABLE_GENERATION_TIMEOUT (data.{ violations: [], skippedSubjectGroups, solveMetadata }, params.outcome = 'TIMEOUT').
  6. FEASIBLE → ingest into a new DRAFT in one $transaction (insertTimetable + scheduledLesson.createMany), then immediately run computeDiagnostics on the persisted draft. If any ERROR violations: roll back the entire transaction and throw 500 (solver/diagnostics-engine drift is a loud bug, not a user-recoverable error). On zero ERRORs: return 201 { 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.