Timetables — Manual Management, Diagnostics & Automatic Generation¶
Two producers of ScheduledLesson rows: (1) an admin or curriculum coordinator 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. The manual flow is template-free : day/week templates are inputs to automatic generation only — the manual grid, the diagnostics engine, and the attendance consumer never touch them.
Builds on homerooms and subject groups. src/timetable-templates/ owns generation inputs.
1. Mental model¶
Core model families (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: aSubjectGroupstarting at a coordinate(weekday, startTick)on the free day axis, in an optionalRoom. Schedule-first-room-later:roomIdmay be omitted at create (and cleared viaPATCH { roomId: null }); a roomless lesson firesLESSON_ROOM_MISSING(ERROR) so publish forces every lesson to be roomed. "Repeats on" is a create-time fan-out:POST …/lessonstakesweekdays: DayOfWeek[]and writes one row per weekday at the samestartTick(all-or-nothing — any coordinate collision rejects the whole batch); nothing is stored as a set, so PATCH/DELETE still address a single row (contrastScheduledActivity, which storesweekdays[]on one row).ScheduledBreak(+ScheduledBreakAudience,ScheduledBreakDuty) — a placed break/lunch slot (BreakKind { BREAK, LUNCH }) with a storeddurationMinutes, an optional room (canteen), an audience (union of department / curriculum / grade / track / homeroom / student selector rows — resolved live to a student set at read time, never materialized), and duties (supervising teachers; real occupancy that consumes the teacher hour budget).POST …/breakstakesweekdays: DayOfWeek[]too, fanning out one break per weekday, each with its own deep-copied audience + duty set.ScheduledActivity(+ScheduledActivityAudience,ScheduledActivityTeacher) — a named non-curriculum slot (morning meet, "Friday Activities", assembly) with a storeddurationMinutesand aweekdays[]repeat set (one uniform time across all its weekdays), an optional room, a live-resolved roster (kind-discriminated audience selector rows:EVERYONE | CURRICULUM | GRADE | TRACK | HOMEROOM | STUDENT, union, never materialized) and supervising teachers (real occupancy, optional, with weekday coverage). Unlike a break, an activity is an obligation for its roster (fills student occupancy) and behaves like a subject group for attendance (chapter 19). See the Activities section below.
Operational student eligibility. Timetable authoring and live
activity/break expansion use only Student.status = ENROLLED rows from the
timetable's academic year. The status/year fence wraps the whole selector
union, so PRE_ENROLLED, LEFT, and GRADUATED students cannot re-enter
through a direct selector or stale-open curriculum/homeroom relation. Cohort
matching retains the display tier: an effective placement wins, while an
enrolled pre-year row with no governing placement keeps its planned head pair.
This rule is timetable-local; student directories and historical surfaces stay
status-neutral, while attendance and record authorization keep their stricter
effective-placement-as-of-date rule.
Audience selectors collapse to their effective set at write time. The FE builds a break/activity audience through a cascading picker (department → curriculum → grade) and posts back the whole drilled path as the flat selector union. A naive union would resolve the broadest row (a department swallows every grade under it) — the opposite of what was drilled into. So on create/update the service collapses the union along the department → curriculum → grade cascade only (resolveEffectiveBreakAudience / resolveEffectiveActivityAudience in timetables.service.ts, keyed by the pure rule in audience-effective.ts): a department is dropped when a curriculum or grade under it (same departmentId) is also present. Curriculum and grade are peers (neither prunes the other), and a dep/cv/grade selector whose department can't be confirmed nested is kept (never dropped on a guess). Everything else is additive — HOMEROOM, TRACK, STUDENT and EVERYONE never prune a dep/cv/grade selector and are never pruned; they contribute their own students on top of the effective dep/cv/grade set (so a department + two homerooms resolves to all of the department's students plus the two classes, not just the classes). Only the effective rows are stored, so the raw read-side union resolvers (resolveBreakAudiences / resolveActivityAudiences) and every downstream reader (diagnostics, views, attendance) inherit the narrowed roster with no read-path change. The collapse can never empty a non-empty audience (it only drops a department that has a surviving cv/grade), so it never trips the empty-audience floor. Store the effective selectors; read-side expansion uses their union.
The coordinate is a start tick: startTick counts 5-minute steps from midnight (96 = 08:00; DTO bounds 0..287) on one absolute axis shared by the FE's free grid, the diagnostics engine, and the solver. There are no slots and no envelopes in the manual flow — the FE renders a free 5-minute grid. A lesson's effective duration is slotDuration ?? resolvedDuration ?? the periodDuration cascade: an optional per-slot slotDuration override (nullable Int, any positive minutes — no multiple-of-5 or max constraint; the day axis is the only bound) resizes one placed lesson; resolvedDuration is the cascade value frozen when the version was published (§5.0 — system-written, never a request field, always null on a DRAFT); when both are null the duration derives from the cascade (cell → subject → grade → curriculum, whose root is NOT NULL) — that fallback is total, so every lesson always has a duration. The cascade is the default, one value per subject group; the override lets a unit mix lengths (e.g. a 6 h subject as 5×1 h + a single 2 h block). A curriculum edit re-resolves only the lessons that carry neither stored value — an overridden slot keeps the admin's number, a published slot keeps its freeze. The override is written only by manual create/PATCH — automatic generation always leaves it null (the solver emits uniform per-group durations). Break/lunch slots are different: their duration is stored on the row (a break has no curriculum to derive from). Geometry helpers live in src/common/utils/period-math.ts (TICK_MINUTES, weeklyHoursToMinutes, resolveEffectivePeriodDuration, canTileExactly); src/common/utils/day-envelope.ts survives as a generation-only consumer.
@@unique([timetableId, subjectGroupId, weekday, startTick]) prevents placing the same group twice at one start tick. Breaks deliberately have no coordinate unique — overlapping breaks in one room are legitimate (canteen lunch shifts).
A subject-group base room is a default, not a constraint. SubjectGroup.baseRoomId is exposed by the placement plan so the FE can preselect it for a new lesson. The lesson's own roomId remains authoritative: an admin may choose any other in-tenant room, and doing so neither creates a diagnostic nor blocks publish. Automatic generation likewise receives the full capacity + subject-room-set compatible room domain; it does not collapse that domain to the base room. Combined-class members may therefore carry different defaults; a combined plan row exposes a default only when its members' non-null defaults agree.
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 — when provided, lessons room later — / subject group / audience refs / duty teachers must be in-tenant), coordinate uniqueness for lessons (
409 SCHEDULED_LESSON_COORDINATE_CONFLICT), the day axis — the interval must not spill past midnight,tickToMinute(startTick) + durationMinutes ≤ 1440(422 SCHEDULED_LESSON_INVALID_SLOTfor lessons viaassertLessonOnDayAxis,422 SCHEDULED_BREAK_INVALID_SLOTfor breaks) — break-audience well-formedness (≥1 selector row,422 SCHEDULED_BREAK_AUDIENCE_EMPTY; exactly one ref per row, elseVALIDATION_FAILED), and direct audience-student eligibility (ENROLLEDin the timetable AY, else422 SCHEDULED_AUDIENCE_STUDENT_NOT_ELIGIBLE). The floor keeps the data coherent. - Soft diagnostics — everything pedagogical (conflicts, capacity, minute 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 per lesson: startMinute/endMinute/durationMinutes (tick × 5 + the periodDuration cascade — total, no null geometry anywhere) plus wallStart/wallEnd strings; alongside come the placed breaks (each with its live-resolved audience student set and duty teachers), the teacher pool (with budgetMinutes = (totalHoursPerWeek + extraHours) × 60, null when unset), rosters, teaching windows, subject-room sets, and option-block maxSelections. All overlap math runs on the numeric minute intervals.
The engine's internal Violation is { category, severity, params, variant? } — a machine key plus a denormalized params payload. That shape is renderer input and never reaches the wire: renderViolationCards(violations) (src/timetables/diagnostics-cards.ts) maps every violation onto a self-contained localized card at the service edge (the same input/wire split as ValidationErrorInput vs the slim validation leaves, docs/06 §2). canPublish(violations) is true iff no ERROR-tier violation exists (computed on the raw engine output).
Violation cards (the wire shape)¶
{
"category": "TEACHER_CONFLICT", // stable machine key (grouping/filtering — not display copy)
"severity": "ERROR", // ERROR | WARNING
"title": { "en_US": "Teacher double-booked", "it_IT": "Docente sovrapposto" },
"messages": {
"en_US": "Anna Bianchi is scheduled in two places at once on Monday",
"it_IT": "…",
},
"refs": { "teacherId": "…" }, // optional headline entity ids (navigate/highlight)
"items": [
// optional detail lines (one per clashing lesson/break/…)
{
"messages": { "en_US": "Math 9A — 08:00–09:00", "it_IT": "…" },
"refs": { "lessonId": "…", "subjectGroupId": "…" },
},
],
}
The FE renders title[lang] / messages[lang] verbatim — no param mapping. Copy lives in src/common/i18n/diagnostic-messages.catalog.ts (DIAGNOSTIC_MESSAGES, keys diagnostic.<CATEGORY>[.<variant>] + .title + diagnostic.item.* + a diagnostic.default fallback for a category the registry doesn't know). One CARD_RENDERERS row per category extracts text params (durations pre-formatted via formatDurationMinutes, weekday/break-kind enums resolved through the :value label modifier), the card refs, and the item lines. variant selects shape-specific sentences (TEACHER_CONFLICT.duty_lesson/.duty_duty, ROOM_CONFLICT.break_lesson, ROOM_CAPACITY.breaks, TEACHER_AVAILABILITY.duty, STUDENT_CONFLICT.break_lesson; the solver stamps generation on every infeasibility core). diagnostics-cards.drift.spec.ts keeps engines ↔ registry ↔ catalog in exact-set lockstep (it also scans solver/app/model.py) and enforces the DIAGNOSTIC_TEXT_PARAMS bijection.
Cards also ride inside three error payloads via COLLECTION_ENRICHMENTS passthrough rows (docs/06 §2): TIMETABLE_GENERATION_PRECHECK_FAILED (sibling skippedSubjectGroups), TIMETABLE_GENERATION_INFEASIBLE and TIMETABLE_GENERATION_TIMEOUT (siblings skippedSubjectGroups + solveMetadata; on TIMEOUT violations is always [], so the base message stands) — leaves ship as-is and the envelope composes from the card messages. TIMETABLE_PUBLISH_BLOCKED deliberately stays count-only (publish is invoked from the diagnostics screen, which already shows the cards).
Breaks and duties in the conflict checks. A break blocks its room for lessons (break × lesson in one room = ROOM_CONFLICT) but break × break in one room is fine — ROOM_CAPACITY checks the peak number of distinct students present simultaneously per (room, weekday). Overlapping breaks, lunches and activities share that count; a student in multiple slots counts once. A chain of overlaps does not add students who are never present together, and a shift ending when another starts does not overlap it. There is at most one violation per overlapping cluster, listing the slots contributing to its earliest peak. A student in a lesson during a break they participate in is a STUDENT_CONFLICT. A duty is real teacher occupancy: duty × lesson and duty × duty overlaps are TEACHER_CONFLICT, a duty on a day off is TEACHER_AVAILABILITY, and duty minutes count toward TEACHER_BUDGET_EXCEEDED.
Activities enter the existing categories as a third occupancy family (see the Activities section for the full semantics): STUDENT_CONFLICT (activity×lesson/×activity/×break variants — an activity is an obligation), TEACHER_CONFLICT + TEACHER_AVAILABILITY (supervisors are real occupancy), ROOM_CONFLICT (activity×lesson in one room; activity×activity/×break co-locate), ROOM_CAPACITY (peak distinct simultaneous roster, including co-located breaks/lunches), STUDENT_GAP (activities fill occupancy and, whole-roster, bridge), SAME_DAY_CONTIGUITY (whole-roster activity bridges), TEACHER_BUDGET_EXCEEDED (+activityMinutes bucket). The WARNING ACTIVITY_SCOPE_EMPTY (an activity whose live-resolved audience matches no students). Activities are invisible to HOUR_BUDGET, option-block, subject-room-set, base-room, LESSON_ROOM_MISSING, and TEACHER_DAILY_CAP.
Exact student gap times. A placed-timetable STUDENT_GAP card lists every positive uncovered interval between the student's earliest lesson/activity start and latest lesson/activity end. Lesson and activity occupancy is combined with same-weekday breaks/lunches whose resolved audience includes that student. A partially covering break removes only its covered minutes; a break in the middle can leave two gaps. Breaks outside the occupied bounds never extend the day. Touching or overlapping coverage is continuous, including nested overlaps; fewer than two occupancies never produce a gap.
The existing localized sentence includes chronological HH:mm–HH:mm ranges separated by ;, with exact minute precision and no grid rounding:
- One gap:
Ada Lovelace has a gap in their Monday schedule: 09:00–10:00. - Multiple gaps:
Ada Lovelace has gaps in their Monday schedule: 09:00–09:30; 11:00–11:15(internalmultiple_gapsvariant).
Italian messages carry the same time ranges. Student refs and lesson/activity detail items remain available. Numeric gaps stay in internal diagnostic params; the public card shape is unchanged. Severity remains WARNING and does not block publication. The solver's generation infeasibility message has no placed times and keeps its existing wording.
Times belong to the evaluated dated snapshot, which can differ from the grid's current membership projection. The card's times describe that snapshot. Different gap arrays remain distinct through dated merging; otherwise identical adjacent violations can still merge.
Core diagnostic categories¶
| Category | Tier | Fires when |
|---|---|---|
TEACHER_CONFLICT |
ERROR | one teacher in two interval-overlapping occupancies (lesson×lesson, duty×lesson, duty×duty) |
ROOM_CONFLICT |
ERROR | one room double-booked (lesson×lesson or break×lesson; break×break allowed) |
STUDENT_CONFLICT |
ERROR | a student double-booked (lesson×lesson on the student's own roster, or lesson × a break they participate in) |
OPTION_BLOCK_SYNC |
ERROR | a maxSelections = 1 block's children don't share the identical (weekday, startTick) set (pick-one ⇒ alternatives co-START; same weekly lesson count falls out). Durations and windows may diverge — a shorter alternative simply ends earlier (durations stay in the card payload for display). Each child's occupancy is expanded (own solo lessons ∪ its active combination's lessons) — a correct pair-tiled combined layout passes with no exemption |
OPTION_BLOCK_CLASH |
ERROR | a maxSelections > 1 block has two different children overlapping in time (a co-pick would clash). Occupancies are expanded the same way; two children covered by ONE shared combined meeting (same lesson row) are not a clash |
ROOM_CAPACITY |
ERROR | lesson roster exceeds Room.maximumCapacity; for breaks/lunches/activities, peak distinct simultaneous occupancy exceeds it (one violation per overlapping cluster) |
ROOM_NOT_IN_SUBJECT_SET |
ERROR | a subject with a non-empty CurriculumSubjectRoom set is placed outside it (gym/lab/music) |
LESSON_ROOM_MISSING |
ERROR | a lesson has no room yet (schedule-first-room-later); one violation per roomless lesson, so publish forces full rooming. Other room-keyed checks (ROOM_CONFLICT lesson×lesson, ROOM_CAPACITY, ROOM_NOT_IN_SUBJECT_SET, SAME_ROOM_DOUBLE) skips roomless lessons and defers here |
TEACHER_AVAILABILITY |
ERROR | a lesson or duty on a weekday outside the teacher's daysOn |
HOUR_BUDGET |
WARNING | Σ placed durationMinutes ≠ the teaching unit's weekly budget. Advisory in both directions: never blocks publish — non-60′ periods make both directions systematic (a "2h" cell scheduled as 2×55′ lands short; as 2×65′ it lands over); the sign of deltaMinutes tells which way. Params expectedMinutes/placedMinutes/deltaMinutes. Per-unit budgets: plain SG → cell minutes; member of an active combination → cell − shared (solo remainder); the combined unit itself → sharedMinutes. Units with no lessons in the timetable are out of scope (an empty draft publishes). The solver still enforces the budget as hard-exact, so generated drafts never carry it. Dated combined coverage: units inside a combination's scope — its shared unit and each valid contributor's solo remainder — are judged per dated segment against DiagnosticsSnapshot.coverage and emit WARNING (variant: combined, params from/until, unplaced units included, shortfall and excess never netted); ordinary courses keep this WARNING |
STUDENT_GAP |
WARNING | positive uncovered time between a student's first and last lesson/activity on one weekday; the message lists every exact HH:mm–HH:mm range after subtracting participating breaks/lunches. Overlapping/touching occupied intervals remain covered; overlap errors stay STUDENT_CONFLICT's job. |
TEACHER_BUDGET_EXCEEDED |
WARNING | teaching + duty + activity + assistance minutes exceed the teacher's weekly budget weeklyHoursToMinutes(totalHoursPerWeek + extraHours) (both support whole-minute fractional hours, e.g. 18.5 = 1110 min); skipped when totalHoursPerWeek is unset; params carry the the four commitment buckets and total/budget minutes split so the FE can show what eats the budget |
TEACHER_DAILY_CAP |
WARNING | a teacher exceeds 4 lessons on one day (lesson-count on purpose — duty load is visible via the budget split instead) |
SAME_DAY_CONTIGUITY |
WARNING | a group's same-day placements aren't back-to-back; a break covering the group's entire roster bridges the pair, a partial-audience break does not |
SAME_ROOM_DOUBLE |
WARNING | an adjacent "double" of one group switches room mid-block |
Teaching windows¶
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 over the same interval (windowsOverlap short-circuits the overlap checks), so two half-year subjects legitimately tile one stretch; a null window is active all year. With dated facts, diagnosticSlices supplies each effective roster/teaching-window slice before the per-(student, weekday) gap pass. Within each supplied snapshot, the gap pass unions overlapping/touching occupancy by retaining its furthest end, including nested intervals; overlap diagnostics remain independent.
4. Mutation envelopes and draft-only content¶
Lesson/break create fans out over weekdays[], so it returns a plural batch — ScheduledLessonBatchResultDto = { lessons[], diagnostics } / ScheduledBreakBatchResultDto = { breaks[], diagnostics } (one entry per requested weekday). PATCH/DELETE …/lessons|breaks keep the singular ScheduledLessonMutationResultDto = { lesson?, diagnostics } / ScheduledBreakMutationResultDto = { break?, diagnostics } — the affected row (omitted on delete). Either way the payload carries the whole-timetable diagnostics recomputed after the edit, canPublish included, so the FE never issues a separate diagnostics request. The N inserts + the single recompute run inside one mutateAndDiagnose lock; content edits are DRAFT-only (branch below), so a batch either applies wholesale on a draft or is rejected wholesale.
mutateAndDiagnose (shared by lesson, break AND activity mutations) locks the timetable row FOR SHARE, re-reads the status in-tx (the authoritative, TOCTOU-safe gate against a concurrent publish, which takes FOR UPDATE), then:
- DRAFT — apply the write, then recompute (non-blocking authoring).
- PUBLISHED — reject with
409 TIMETABLE_EDIT_REQUIRES_REVISION. A published timetable's content is frozen: edits go through a revision (§5.1) so consumers never observe mid-edit state and staged changes are published atomically. ARCHIVED — reject with409 TIMETABLE_READ_ONLY.
GET …/diagnostics runs the same computeDiagnostics for an on-demand report. Break PATCH semantics: scalars are sparse; audience and dutyTeacherIds, when present, are full-replace. duplicate copies the lessons and every break with its audience + duty rows.
5. Lifecycle & publish gate¶
PUT …/status toggles DRAFT ↔ PUBLISHED only (ARCHIVED is rejected at the DTO — see below) and is the first publish of a never-published draft. Publishing requires, in order:
- The timetable is not
ARCHIVED(409 TIMETABLE_READ_ONLY). - It is not a revision draft (
409 TIMETABLE_NOT_A_REVISION) — a revision is promoted via publish-revision (§5.1), never by flipping its own status. - 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).
A successful publish stamps Timetable.publishedAt = now().
Publication notification. A real first transition to PUBLISHED emits
timetable.published after commit; an idempotent PUBLISHED-to-PUBLISHED request
emits nothing. Audience is resolved against the new version's effectiveFrom,
not the request date: lesson and combined-class teachers/rosters, supervised
activity teachers/audiences, and break/lunch duties/audiences all participate.
Active linked teacher users, active users of involved ENROLLED students, and
those students' active referents are deduplicated by user. Each student's own
payload carries their student ID and relevant departments; a missing or inactive
student account does not exclude their active referents. Non-ENROLLED students
contribute neither their own user nor referent recipients. A dual-role user
retains both teacher and linked-child context in one destination-neutral payload.
The stable timetable/year IDs,
revision, effective date, and recipient-relevant teacher/student/department IDs
let FE choose a destination later without a backend route discriminator.
5.0 Valid time — which version governs which dates¶
Publication is a dated act. publishedAt/supersededAt say when we acted (transaction time); effectiveFrom/effectiveUntil (@db.Date, half-open [from, until)) say which dates a version governs (valid time). The two are not duplicates and neither pair may be collapsed into the other — the default effective date can be tomorrow (when today's register conflicts with the incoming content), so effectiveFrom != date(publishedAt) in general.
Interval shape is tied to status by the raw CHECK timetables_effective_shape_chk — DRAFT NULL/NULL, PUBLISHED set/NULL (open), ARCHIVED set/set. A second CHECK, timetables_effective_order_chk, keeps from <= until; equality is legal and means the empty interval [X, X): retained evidence of a version superseded before it ever governed a day. It matches no date.
Resolution never consults status. findTimetableVersionEffectiveOn(db, tenantId, ayId, date) (exported from the src/timetables barrel) is the canonical by-date lookup — DRAFTs self-exclude because NULL <= D is never true, and empty intervals self-exclude because until > D fails for every D >= from. It may return the stable published id or an archived copy's id: the geometry of date D is whichever row governs D. Its ranged twin, findTimetableVersionsOverlapping(db, tenantId, ayId, from, to), returns every version that governs some date of [from, to] under the same rules, and timetableVersionCovers(version, date) picks the one governing a date in memory. Change the predicate in all three together.
The effective date of a publish is resolved once inside the command transaction. Omitted and null effectiveFrom both mean the default; use resolvePublishEffectiveFrom for this rule.
| Case | Result |
|---|---|
| no date sent, no version ever effective in this AY | today (school timezone) — the first-publication exception; no probe, no cell can exist |
| no date sent, frontier beyond today | the frontier — no probe, no cell can exist on a future date |
| no date sent, otherwise | today if no cell recorded today conflicts with the incoming content, else tomorrow — a fallback, never a refusal |
| a date earlier than the minimum | 422 TIMETABLE_EFFECTIVE_DATE_INVALID, reason: 'BEFORE_MINIMUM' |
| today, and no recorded cell conflicts | accepted |
| today, and a recorded cell conflicts | 422 …, reason: 'CELLS_EXIST_TODAY' |
"Conflicts" is anchor survival (findCellAnchorsIncompatibleWith, timetables.queries.ts), judged against the incoming content on today's weekday: lesson cells by stable (subjectGroupId | combinedClassId, startTick) plus compatible ordinary teacher evidence; activity cells structurally by snapshot (activityName, startTick), because activity rows are version content and their ids never survive a copy. Only today's cells matter — past days stay governed by the closed interval, and the write path refuses future dates. The register is the only thing a schedule swap can strand; when it strands nothing, the edit is live immediately — the dateless default and the explicit-today gate share this one predicate.
Dates are school-timezone calendar days, never the server's. Both today-arms probe under an exclusive AY-day advisory lock (lockAyDayExclusive, src/common/temporal/) which attendance's cell writes take on the shared side, keyed on the cells' own date — so publish-today and a register take serialize instead of both observing an empty day. Responses carry effectiveFrom and, on publish, the identical appliedFrom (the program-wide name for a resolved application date) — with a data-sensitive default the FE must render appliedFrom rather than assume either day.
Unpublish is retain-and-close, not a status flip: the outgoing version is retained as an ARCHIVED copy closing at max(effectiveFrom, tomorrow) — today stays governed, so a register taken this morning keeps the schedule it was taken against — and only the live row's valid time is cleared (publishedAt is transaction time and is never cleared). A version that never became effective collapses to [X, X). The demoted row also drops its resolvedDuration freeze (clearResolvedDurations, strictly after the archive copy has carried it away) — it is a DRAFT again, and a DRAFT derives live.
Delete is gated by governed history. A lineage that has governed at least one school day can no longer be deleted: 409 TEMPORAL_ROW_IMMUTABLE. A lineage whose first version became effective today has governed a day only once one of its lessons has started (startTick < nowTick on today's weekday — versionsHaveStartedLessonToday); the same test narrows the content-reference arm's lesson legs for versions effective from today, while break and activity audiences keep the version-date rule. Never-governed lineages — standalone drafts, pending versions whose only archives carry empty intervals — stay deletable. Unpublish is the alternative: the version stays on record and stops governing future days.
Durations freeze at publish, in their own column. ScheduledLesson carries two nullable duration columns and they are not interchangeable:
| Column | Written by | Meaning of NULL |
|---|---|---|
slotDuration |
the admin, via lesson create/PATCH | no override — derive the length |
resolvedDuration |
stampResolvedDurations only, on every transition into PUBLISHED |
not frozen — a DRAFT, or a lesson whose slotDuration already decides it |
resolvedDuration is internal — it is not in any DTO and never reaches a client. The API exposes the effective durationMinutes and the authored slotDuration; together those answer "how long is it" and "did someone set that deliberately", which is the whole of what a client needs. Putting the freeze on the wire would publish a field whose meaning is "which publish this content came from" and invite a round-tripped PATCH to turn it into a real override.
Read order is slotDuration ?? resolvedDuration ?? cascade: authored beats frozen beats live. Live derivation ends at publish, so a curriculum edit can no longer re-cost a day that has already been governed; a revision draft drops the freeze (copyTimetableContent) and derives live again, so edits made since the last publish do land. The ARCHIVED retention copy is the one copy that carries the freeze — it must record what the version cost while it was live, not what the curriculum says at archive time.
Keep authored overrides separate from lifecycle freezes. Stamping slotDuration would erase the difference between a user choice and an inherited value, and would carry that accidental override into subsequent revisions.
ARCHIVED versions are read-only. Retain-and-swap publication and unpublication create archived copies; clients cannot request ARCHIVED through the status DTO.
5.1 Revision & republish (staged modify-then-publish)¶
A PUBLISHED timetable's content is frozen (§4) — consumers read it live (attendance resolves the governing version by date), so an in-place edit would leak mid-reorg state. To change it:
POST …/revision(createaction) deep-copies the published timetable's content (lessons/breaks/activities + sub-rows, viacopyTimetableContent) into a new DRAFTRwithR.revisionOfId = published.id. At most one open revision per published — the partial uniquetimetables_open_revision_unique(WHERE status = 'DRAFT'); idempotent — an already-open revision is returned. The FE editsRthrough the ordinary lesson/break/activity CRUD (addressed byR's own id, frictionless DRAFT rules).POST …/publish-revision(publishaction) is the atomic retain-then-swap: lockRFOR UPDATE(content edits takeFOR SHAREon it), then the published parentFOR UPDATE; re-validate under the lock thatRis still an open revision (guards the discard-during-publish race); run the publish gate onR's content; thenarchivePublishedVersion(published)→clearTimetableContent(published)→copyTimetableContent(R → published)→ stamppublishedAt+ the nextrevisionNumber→ hard-deleteR, all in one transaction. The published id is stable, so consumers flip old→new at commit and any reference to the id survives.- Discard =
DELETE …/:R(ordinary draft delete); the published timetable is untouched.
Version retention. A republish retains the outgoing content. archivePublishedVersion copies it to a new row with status: ARCHIVED and the same revisionOfId — one lineage pointer, the open DRAFT ahead of the published row and the retained versions behind it. Consequences worth knowing:
- An archived copy has
revisionOfIdpointing at its lineage root. It is read-only and direct deletion is rejected. - The retained copy carries the published row's
publishedAtplus a freshsupersededAt, so it records the window it was live in, and arevisionNumber(1-based per lineage; first publish sets 1, each republish increments the published row). It carries its own valid-time window[effectiveFrom, effectiveUntil)— and that one IS a resolution mechanism (§5.0): a republish closes the outgoing interval at exactly the boundary the incoming one opens at, one date per command, so the lineage stays contiguous and gap-free. (The transaction-time window remains documentation only.) -
Its lessons preserve
resolvedDuration, so archive retention records published geometry rather than re-deriving it from a later curriculum edit. See §5.0. -
Growth is a full content copy per republish, retained indefinitely. ARCHIVED blocks direct
delete, and once the lineage has governed a day the published parent is protected byTEMPORAL_ROW_IMMUTABLE, so effective history has no API removal path. Deleting the parent cascades only for a lineage that never governed. - Summary DTOs expose
publishedAtand the revision relationship, while internal retention metadata includessupersededAtandrevisionNumber. Historical teaching attribution is resolved through dated teaching/member facts and each lesson's stored selection; it is not a frozen copy of a current course's teacher array.
findAll (the list) hides revision drafts and retained versions with one revisionOfId: null filter — a revision is reachable only via the published timetable's openRevisionId, a retained version only by its own id. For the same reason findOpenRevision matches on status: 'DRAFT' as well as the pointer, and timetableSummaryInclude filters revisions to DRAFT: without those, a retained version reads as a pending change. The DTO exposes publishedAt, hasPendingChanges (= an open revision exists), openRevisionId, and revisionOfId. A successful revision swap emits the same timetable.published kind with changeType: REPUBLISHED, resolved from the copied incoming content on its new effective boundary and deferred through PostCommitCoordinator; discard, rejected, and no-op paths emit nothing.
6. Read views & placement plan¶
GET …/lessons takes a faceted filter: one optional multi-value param per kind — departmentId | curriculumId | gradeId | homeroomId | subjectGroupId | combinedClassId | roomId | teacherId | studentId (repeat the param or send a comma-separated list, per the platform filter convention) — and returns TimetableLessonsViewDto { timetableId, targets, lessons, breaks, activities, assistanceShifts }. Values of one param union (a slot matches any of them); different params intersect — slot-level AND, a slot must independently match every kind present (for example, homeroomId + teacherId selects that teacher's slots in that class). The response echoes targets: {view, id, displayName}[] — catalog order across kinds, request order (deduped) within a kind, each entry tagged with the kind it was selected under; all params omitted = the whole-school read (every lesson and break of the timetable, targets: []). There are no sequence grids — the FE renders a free 5-minute grid; lessons carry their own wallStart/wallEnd/durationMinutes. Every lesson carries unit: TeachingUnitRefDto { kind: SUBJECT_GROUP | COMBINED_CLASS, id, displayName, subjectName }; a combined lesson's teachers apply the lesson's ALL/SELECTED rule to its dated eligible team. The homeroom facet is roster-derived (the lessons of the SubjectGroups its rostered students attend); department filters through subjectGroup.grade.departmentId, curriculum through subjectGroup.curriculumSubject.curriculumId; combined lessons satisfy each facet wherever any member matches it — per facet, so under an AND different members may satisfy different facets (student via the contributing membership, teacher via the union teacher set, room/grade/department/curriculum via any-member match).
Breaks per facet (findBreaksForView): the whole-school read returns all; the teacherId facet keeps the breaks where a selected teacher holds a duty; roomId matches the break's room; every student-anchored facet keeps the breaks whose live-resolved audience intersects that facet's student cohort. Facets apply sequentially over the row set (slot-level AND); the audience resolution runs once, up front, when any student-anchored facet is present. findActivitiesForView mirrors this with roster ∩ cohort / supervising teacher / room. The teacher facet means “assigned on at least one activity weekday”; each returned teacher ref carries its effective weekdays[], so the client can render the per-day split without duplicating the activity. Lesson WHERE composition plus both selectors live in timetable-view.queries.ts and serve the grid service.
Grid filter options — anchored surface=timetables. The seven federated
option endpoints for departments, grades, curricula, homerooms, rooms,
teachers, and students require timetableId. FiltersService resolves that
anchor through TimetablesPolicy (findFilterTimetableAnchor in
filters.queries.ts), makes its academicYearId authoritative (an explicit
academicYearId that disagrees is the same 404), and then offers every row
of the anchor year — the grid's placed cells play no part. The cascade narrows structurally: grades by departmentId;
curricula by departmentId and CurriculumGrade coverage of gradeId;
homerooms through buildHomeroomsTableWhere; rooms are the whole year;
teachers are the whole year intersected with TeachersPolicy (status-neutral,
like the other surfaces). Non-author DRAFT/ARCHIVED anchors remain 404; missing
timetable READ is 403. Student options are authoring candidates in the
anchored AY, intersected with StudentsReadPolicy, restricted to ENROLLED,
and optionally narrowed by display department/grade — never intersected with
students already used by the timetable. Existing filters remain left-to-right
rather than self-excluding facets.
Duty-derived record visibility. A teacher assigned to a break/lunch duty is related, for record-level student reads, to every strictly placed student selected by that break's effective STUDENT | DEPARTMENT | GRADE | CURRICULUM | TRACK | HOMEROOM audience while the timetable governs the school-clock date. Effective placement is the outer candidate universe for all six legs, so a direct ref or stale-open membership cannot grant access before entry or after exit. The inverse communications-recipient lookup uses the same six selector legs. EVERYONE is not a break selector and is not part of this relationship. This relationship is independent of the break's stored weekday because record visibility is date-scoped by the governing timetable window, not by whether that particular duty occurs on the queried weekday. Canonical policy details: chapter 04.
Placement plan — GET …/placement-plan (same faceted grammar as the lessons read; echoes the same kind-tagged targets too; combined classes join the scope per facet via any-member match — AND of subjectGroups: { some: <facet predicate> }) returns one row per teaching unit: { kind, unit, subjectName, department, curriculum, grade, teachers, studentCount, weeklyHours, expectedMinutes, slotDurationMinutes, placedMinutes, remainingMinutes, baseRoom?, combinedClassId? }. Minutes-first: placedMinutes = Σ each placed lesson's effective duration (slotDuration ?? cascade) — summed per unit (via sumPlacedMinutes*), since a slotDuration override lets durations vary within a unit; slotDurationMinutes in the row is the cascade default used to seed a new slot. The aggregate deliberately ignores resolvedDuration (a groupBy cannot express the coalesce): the plan steers editing, which happens on DRAFTs, where nothing is frozen. The residue is that a PUBLISHED version whose curriculum moved after publish is costed here at live-cascade minutes while its board renders the frozen ones — advisory surface, non-editable target. A combined class is its own row (kind: COMBINED_CLASS, expectedMinutes = sharedMinutes, union studentCount/teachers, derived department, subjectName/curriculum/grade null); each member keeps a contributor row whose expected minutes shrink to the solo remainder (cell − shared) and carries combinedClassId so the FE can explain the reduction. This is the FE's "what's left to place" panel and pairs with HOUR_BUDGET's per-unit budgets.
7. RBAC¶
Admins and curriculum coordinators author; reads are tenant-wide. One entity EntityKey.TIMETABLES has a descriptor-only configuration scope (flat DTOs, no FIELD_MAPPINGS row) and four actions: create, delete, publish, generate. Admin receives WRITE and all actions through ALL_WRITE/ALL; curriculum_coordinator explicitly receives the same timetable grants. Coordinator timetable management covers the whole school, independent of assigned curriculum parameters. Other presets retain READ. This does not widen their permissions on other entities or on timetable templates.
Read routes are gated by scope, not role. GET /, GET /:id, and GET /:id/lessons carry @RequireScopes(TIMETABLES,'read') + @AppliesPolicyDimensions(TimetablesPolicy) without a role gate. The policy's hand-built WHERE admits every status for admins, platform admins and curriculum coordinators; other readers see only PUBLISHED. loadOrThrow/findAll apply this tenant fence, so other readers requesting a draft receive TIMETABLE_NOT_FOUND. Anchored timetable filter lookups reuse the same policy. Archived versions remain read-only, and published content still requires the existing revision workflow.
Write, action and planning routes share author admission. @AppliesPolicy(TimetablesPolicy) admits admin and curriculum_coordinator for create, generate, rename, delete, duplicate, revision, publication/status, lesson/break/activity/assistance CRUD, diagnostics and placement plans. Existing scope/action guards still apply; curriculum parameters alone do not admit custom roles. isTimetableAuthor in the same policy file also controls eligible lesson-teacher choices and, together with readable timetable scope, post-save diagnostic review notices. It does not grant a general admin bypass.
Frontend controls use timetable WRITE/action grants together with the author policy. The preset source and reconciliation rules are documented in permissions.
8. Automatic generation¶
Templates are generation-only inputs : the day/week template catalog and the per-grade effective-template resolution (resolveEffectiveTemplatesForAcademicYear + EffectiveTemplate, relocated to src/timetables/generation/effective-templates.ts) are consumed exclusively by this pipeline. Nothing outside generation/ may depend on them.
Pipeline¶
POST /timetables/generate (@RequireAction(TIMETABLES, 'generate'), admin or curriculum coordinator) runs:
- Snapshot —
buildGenerationSnapshot(prisma, tenantId, academicYearId)assembles segments, day envelopes (per operative(template, weekday): start/end/default duration/breaks/teaching minutes), groups (withweeklyMinutes,durationByWeekdayfrom the cascade, compatible rooms, windows), coverage cohorts (each a distinct student course-set, deduped — the compactness unit), option-block cohorts, teacher availability, and pre-computed roster-clash pairs. - Pre-checks —
runPreChecks(snapshot)runs 8 pure checks (see table below). Any violation →422 TIMETABLE_GENERATION_PRECHECK_FAILED(data.violationsas rendered cards + theskippedSubjectGroupssibling). 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 }). The solver's unsat-core refs are enriched with{ id, displayName }from the snapshot, stampedvariant: 'generation', and rendered as cards through the shared categories'.generationsentences (§3). - TIMEOUT →
422 TIMETABLE_GENERATION_TIMEOUT(data.{ violations: [], skippedSubjectGroups, solveMetadata }— same shape as INFEASIBLE; the violations list is always empty on timeout, so the envelope keeps the base message). - FEASIBLE → assignments arrive as
{ subjectGroupId, weekday, startMinute, roomId }wheresubjectGroupIdmay carry a combined-class id (the snapshot handed the solver the combination under its real id); the ingest maps each assignment directly to one row — a combined id lands as{ subjectGroupId: null, combinedClassId }, anything else as a plain SG row (no fan-out).startTick = startMinute / TICK_MINUTES(a non-multiplestartMinutethrows500before the persist transaction — it can only mean solver drift). Persist into a newDRAFTin the phase-W 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 }.
Transaction shape: the route is @NoTenantTx. R opens a short withTenantGuc transaction for academic-year resolution, the captured school day, and the structural snapshot; prechecks then produce the solver request and a canonical structural fingerprint. S awaits the solver with no database transaction or pooled connection held. R and S are side-effect-free and outside coordinator scope. W runs postCommit.runScoped(() => withTenantGuc(...)): it re-resolves the year, rebuilds with the phase-R school day, re-runs prechecks before mapping, and compares the canonical fingerprint before writing. Any year mismatch, new precheck violation, or structural fingerprint mismatch returns 409 TIMETABLE_GENERATION_STALE; the write transaction rolls back and nothing is persisted. Phase-R precheck failure remains 422.
Skipped subject groups — STANDALONE groups with no grid cell (reason: HOURS_NULL) or whose weekly minutes no combination of their available lesson durations sums to (reason: HOURS_NOT_EXPRESSIBLE, via canTileExactly) are excluded from the snapshot before the solver sees them. Fractional hours are supported — 1.5 h is two 45′ lessons. 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, STUDENT_GAP, TEACHER_CONFLICT, ROOM_CONFLICT, STUDENT_CONFLICT, OPTION_BLOCK_SYNC, OPTION_BLOCK_CLASH) correspond to the engine's checks — three of them the solver keeps stricter than the engine: HOUR_BUDGET and STUDENT_GAP are engine-WARNINGs the solver keeps hard, and OPTION_BLOCK_SYNC is engine-relaxed to co-start while the solver encodes full identical-interval co-slotting (plus the strict OPTION_BLOCK_CHILD_MISMATCH pre-check) — stricter-than-the-gate is the safe direction, so generated drafts always pass. 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.
Band fit is solver-internal. The solver's x-domains only contain start ticks whose interval fits inside one teaching band of the group's template envelope, so a generated lesson can never straddle a template break. The manual diagnostics engine does not enforce generation template bands. Subject-group base rooms do not participate in generation validity: compatible room domains derive only from capacity + subject-room-set filtering. Note the solver also still enforces HOUR_BUDGET and STUDENT_GAP as hard families while the engine reports them as WARNINGs — generated drafts are simply stricter than what publish requires.
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. Per-student compactness (STUDENT_GAP) is checked per segment.
Option-block cohort scoping¶
The generation snapshot and the diagnostics engine key option-block cohorts identically:
maxSelections |
Cohort key | Constraint |
|---|---|---|
= 1 |
(blockId, gradeId) |
SYNC — engine: all children co-started (identical start sets; durations may diverge). Solver: still full co-slot (one b variable per cohort) |
> 1 |
(blockId, gradeId) |
CLASH — no two children may overlap in time |
Both cohort keys are grade-scoped. The diagnostics engine and solver use the same cohort identity.
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 in the snapshot has null/zero weekly minutes, or minutes that canTileExactly can't express with the group's available lesson durations (skippable STANDALONE groups with no cell were dropped at classification — this catches groups that must block generation instead) |
NO_EFFECTIVE_TEMPLATE |
A schedulable group has no resolved week template |
ROOM_UNPLACEABLE |
A group has no compatible rooms (after capacity + subject-room-set filtering) |
TEACHER_CAPACITY |
A teacher's assigned weekly minutes exceed capacity in any segment; capacity = Σ over daysOn of the largest teachingMinutes envelope among the templates their groups use that day. Combined members already carry reduced minutes; a paired maxSel=1 block child keeps its FULL cell on the wire, so this check subtracts its combination's shared quota (the COMBINED group carries those minutes) to avoid double-counting |
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 weekly minutes, teaching windows, or durationByWeekday (equal durations are what lets children swap seamlessly per lesson) |
COMBINED_NO_COMMON_ROOM |
A combined class's members share no compatible room (the COMBINED group's room-intersection is empty) |
COMBINED_NO_COMMON_BAND |
A combined class's members' teaching bands never intersect on any shared weekday (the intersection template has no operative day) |
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 }; assignments carry startMinute on the absolute axis.
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 (tick-grid packing): x[(group, weekday, tick)] (placement BoolVar over the allowed start ticks — those where the lesson's duration_by_weekday interval fits inside ONE teaching band of the template's envelope, weekdays filtered by teacher days_on), y[(group, room, weekday, tick)] (room assignment BoolVar over every capacity + subject-room-set compatible room), b[(cohortKey, weekday, tick)] (block-unit BoolVar for maxSelections=1 cohorts over the children's domain intersection). A placement covers ticks t..t+dur/5−1. HOUR_BUDGET is Σ x·duration == weeklyMinutes per group; feasibility depends on each group's budget. Groups carry kind: STANDALONE | BLOCK_CHILD | COMBINED and combined_group_id; pair-tiling extends the block-sync family for a maxSelections=1 child paired to a combination: at every tick of the block domain x_child(t) + x_cc(t) == b(t) (the child and its combination tile the block unit together — the child keeps its FULL cell budget through the block-unit sum while the combination's own HOUR_BUDGET fixes the shared share, so the child's solo minutes are the arithmetic remainder), and the combination places nothing outside its paired cohorts' block domains (multi-cohort pairing → the intersection). STUDENT_GAP enforces per-coverage-cohort compactness: for each (cohort, weekday, segment), Σ placed − Σ adjacencies ≤ 1 under one assumption literal. Because a cohort's lessons never overlap (guaranteed by STUDENT_CONFLICT + OPTION_BLOCK_CLASH), runs = placed − adjacencies, so ≤ 1 means exactly one contiguous run = no teaching gap (an adjacency is two placements where one ends exactly where the next begins, with a fixed break band normalized as adjacent). 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. The test suite verifies solver output through an independent mirror oracle (solver/tests/mirror.py) that re-derives every predicate from scratch, including band geometry and the combined pair rules.
Soft objective (10 terms): 3 warning-mirror terms (teacherDailyCap, sameDayContiguity, sameRoomDouble) + 7 quality terms (teacherDaySpan, doublesPreference, departmentRoomPreference, subjectGroupRoomStability, evenWeeklyDistribution, teacherPresence, soloIsolatedPeriods). Weights are passed per-request from the BE (DEFAULT_SOFT_WEIGHTS in solver.port.ts); zero weight skips the term entirely. Under packing, adjacency = one lesson ending exactly where the next starts (a fixed break band between them still counts), and the contiguity-flavoured terms count extra runs instead of rank holes; evenWeeklyDistribution balances minutes per day; doublesPreference qualifies groups by weekly lesson count (weeklyMinutes / duration). 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.
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/common/utils/period-math.ts # TICK_MINUTES, weeklyHoursToMinutes, resolveEffectivePeriodDuration, canTileExactly
src/common/utils/day-envelope.ts # DayEnvelope geometry — generation-only geometry
src/timetables/audience-effective.ts # pure break/activity audience collapse: dep→cv→grade cascade only (homeroom/track/student/everyone additive), write-time selector normalization
src/timetables/activity-teacher-coverage.ts # global-sentinel expansion, per-weekday membership, and parent-weekday reconciliation
src/timetables/timetables.diagnostics.ts # pure window-aware minutes engine (the heart): dated diagnostic categories
src/timetables/timetables.queries.ts # CRUD + copyTimetableContent/clearTimetableContent/findOpenRevision + buildDiagnosticsSnapshot + cascade/response loaders + break graph + activity graph + resolveBreakAudiences/resolveActivityAudiences + placement-plan queries
src/timetables/timetable-view.queries.ts # shared lesson/activity/break faceted selection engine used by grid reads and filter projection
src/timetables/timetables.service.ts # lifecycle, structural floor, mutateAndDiagnose (PUBLISHED frozen), startRevision + publishRevision (atomic swap), break + activity CRUD, publish gate, grid projection + placement plan
src/timetables/timetables.controller.ts # routes, access decorators, mutation and generation contracts
src/timetables/timetables.policy.ts # school-wide author access; published-only for other readers
src/timetables/dto/ # timetable / lesson / break / activity / view / diagnostics / placement-plan / mutation-envelope DTOs
src/timetables/generation/effective-templates.ts # resolveEffectiveTemplatesForAcademicYear + EffectiveTemplate (generation-only)
src/timetables/generation/generation.queries.ts # buildGenerationSnapshot: segments, day envelopes, groups (minutes + durations), cohorts, clash pairs
src/timetables/generation/combined-rewrite.ts # applyCombinedClassRewrite: first-class COMBINED group + in-place member reduction (maxSel=1 children keep the full cell)
src/timetables/generation/generation.prechecks.ts # runPreChecks: 8 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 → tick 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 tick-grid packing: x/y/b vars over allowed ticks + 7 hard families via assumption literals
solver/app/objective.py # weighted quality and warning objectives
solver/app/solve.py # budget split, FEASIBLE extraction (startMinute), INFEASIBLE unsat-core minimization
solver/app/schemas.py # Pydantic request/response schemas (DayEnvelope, GenerateRequest, GenerateResponse, …)
solver/tests/mirror.py # independent oracle: re-derives every hard predicate over concrete placements
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. To change the publish gate: TimetablesService.setStatus. To add a view: extend the shared predicates/selectors in timetable-view.queries.ts, the target resolver and placement-plan predicate in the service, and the owning filter queries when the new kind contributes options. 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.
Attendance is a consumer (chapter 19): the take-attendance path resolves a lesson's wall interval via resolvePeriodSlot(startTick, cascade) (total, template-free) and derives a grade's operative weekdays from the published timetable's placed lessons or any attendance-bearing activity whose roster includes a student of the grade (loadOperativeWeekdaysForGrade) — a weekday with no placed lesson AND no activity for the grade is not a school day for it. Activities are a take anchor alongside lessons/combined classes.
Combined classes (first-class teaching units)¶
A combined class links 2+ SGs as taught together (see 14 - Homerooms & Subject Groups for the entity). The shared meeting is one lesson row anchored on the combination itself — there is no co-location simulation anywhere.
Dual anchor. ScheduledLesson.subjectGroupId XOR combinedClassId
(DB CHECK scheduled_lessons_anchor_xor; coordinate uniqueness is per anchor —
@@unique([timetableId, subjectGroupId|combinedClassId, weekday, startTick])).
POST …/lessons accepts exactly one of the two ids (class-validator
@ExactlyOneAnchor → 400 on none/both); PATCH/DELETE address a combined lesson
by its own lessonId like any lesson. Deleting the combination cascades its
lessons (onDelete: Cascade); the members' solo lessons survive. duplicate
copies the anchor verbatim. A combined lesson's duration resolves through any
member's cascade (the combine-time gate guarantees agreement); its ordinary eligibility is the dated union of its members, narrowed by the lesson's ALL/SELECTED choice; its roster is the distinct union.
Diagnostics. A combined lesson contributes one physical meeting with a distinct union roster and its actual regular teachers. Checks include per-unit HOUR_BUDGET
(combined unit → sharedMinutes; active member → cell − shared; see the
catalogue — dated and WARNING-tier inside a combination's scope), the expanded option-block occupancy (a block child's occupancy
= its solo lessons ∪ its active combination's lessons — SYNC compares expanded
tick-sets with no exemption, CLASH counts a combination's lessons for a paired
child but never flags two children covered by the SAME shared meeting),
COMBINED_SHARED_EXCEEDS_CELL (ERROR — only via a post-combine curriculum
edit), and COMBINED_CLASS_DEGENERATE (WARNING — a <2-member combination is
inert; its lessons stay placed and the warning flags cleanup — ERROR when it
still owns lessons in the judged version: the dated coverage rule below). The snapshot
carries combinedClasses (members + sharedMinutes) and a memberToCombined
map.
Layered duration gate — members must resolve to equal lesson durations.
Enforced decidable-earliest-first: combine-time 409 (curriculum-resolved), fit
report (GET /timetable-assignments → COMBINED_DURATION_MISMATCH /
COMBINED_SHARED_NOT_DIVISIBLE / COMBINED_CLASS_DEGENERATE, template-tailed),
then the generation prechecks (COMBINED_NO_COMMON_ROOM /
COMBINED_NO_COMMON_BAND).
Generation — first-class COMBINED group.
generation/combined-rewrite.ts::applyCombinedClassRewrite (called inside
buildGenerationSnapshot) adds, for each active combination (≥2 fully
schedulable members), one group under the real cc.id with
kind: 'COMBINED': weeklyMinutes = shared, union roster/teachers,
∩ compatible rooms (refiltered by the summed roster), riding a synthetic
intersection week template cc:<ccId> whose per-weekday bands are the
intersection of the members' real templates' bands. Members stay in the group
list under their own ids with combinedGroupId = cc.id:
- standalone +
maxSelections>1block children are reduced in place tocell − shared(fully shared → dropped from the group list); maxSelections=1block children keep their FULL cell — the solver's block-unit budget runs on the first child's minutes and pair-tiling (x_child + x_cc == bover the block domain) makes the solo remainder fall out arithmetically;- null-minutes block children are left untouched (
SUBJECT_HOURS_INVALIDowns that failure).
The solver addresses a combination by its real id. Ingestion maps it to one combined-anchored lesson. Roster-clash pairs use the combined roster through the ordinary clash computation; option-block children can participate.
Activities (named non-curriculum slots)¶
An activity is a named slot that isn't a curriculum subject — a morning
meet, "Friday Activities", an assembly. It is timetable-scoped (break-style:
self-contained rows, copied by duplicate, gone when the timetable is deleted),
and belongs to exactly one department. A cross-department event is represented by separate activities so each register has an unambiguous attendance mode.
Model. ScheduledActivity { name, departmentId (required, Cascade),
weekdays: DayOfWeek[], startTick, durationMinutes (stored), roomId? } +
ScheduledActivityAudience (kind-discriminated selector rows — six kinds; the department is stored on the parent) + ScheduledActivityTeacher {
teacherId, weekdays: DayOfWeek[] } (supervisors, optional). On the child row,
an empty stored array is the global sentinel: the
teacher covers every parent activity weekday. A non-empty array is an explicit
subset. Responses never expose the sentinel; every teacher ref returns the
effective weekdays[] expanded against the parent. The parent weekdays[]
array is the repeat set at one
uniform time — a different Friday time is a second activity (divergence from
the one-coordinate-per-row norm; the diagnostics/attendance code expands per
weekday). Raw-SQL CHECKs: scheduled_activities_axis_chk (5′ grid, inside the
day, non-empty weekdays) and scheduled_activity_audiences_kind_ref_chk
(EVERYONE ⇒ no ref, every other kind ⇒ exactly its own). No coordinate
unique — overlap wrongness is diagnostics' job. All three tables are
RLS-covered + in tenanted-models.ts.
Roster. resolveActivityAudiences (in timetables.queries.ts) live-resolves
the union of the audience rows to a student set in ONE query, keyed per kind:
EVERYONE → the activity department's placed students, GRADE → gradeId,
CURRICULUM → StudentCurriculumSelection.curriculumId, TRACK → …trackId,
HOMEROOM → HomeroomAssignment, STUDENT → the student — every kind fenced
to ENROLLED students in the timetable AY and to the activity's department
(write-time validation pins the ref, not its
members, so a student who moved department drops out here). Never
materialized — moves re-resolve on the next read. The register does NOT use
this expander: attendance expands as-of-D through expandAudienceOn
(attendance), which carries the same department fence.
CRUD. POST/PATCH/DELETE /timetables/:id/activities[/:activityId] — admins and curriculum coordinators
(@RequireScopes(TIMETABLES,'write'), existing timetable grants), riding the same
mutateAndDiagnose envelope as breaks/lessons ({ activity?, diagnostics };
DRAFT diagnostics do not block edits; PUBLISHED content requires a revision). Create requires
departmentId; every audience ref must belong to it
(SCHEDULED_ACTIVITY_AUDIENCE_OUT_OF_DEPARTMENT 422), and a PATCH that changes
the department revalidates the stored audience when none is provided — a
department switch cannot strand out-of-department refs.
Direct studentId refs additionally must identify an ENROLLED student in the
timetable AY (SCHEDULED_AUDIENCE_STUDENT_NOT_ELIGIBLE 422); activity
department validation uses the student's display cohort on the captured school
date rather than a future forward-head move. audience and either
teacher input full-replace on PATCH. New clients send teachers: [{ teacherId,
weekdays? }]; omitting an assignment's weekdays means all parent days.
Legacy teacherIds[] remains accepted with the same all-days meaning, but
teacherIds and teachers are mutually exclusive. Explicit child weekdays
must be a unique, non-empty subset of the parent. A parent weekday-only PATCH
intersects explicit subsets (deleting an assignment that becomes empty), does
not widen them when a day is added, and leaves global assignments global.
Structural floor: in-tenant refs, day axis
(SCHEDULED_ACTIVITY_INVALID_SLOT), non-empty audience (@ArrayMinSize(1) at the
DTO → 400; the service SCHEDULED_ACTIVITY_AUDIENCE_EMPTY 422 is the bypass
backstop), everyone-XOR-ref and invalid teacher coverage (VALIDATION_FAILED).
duplicate copies activities with their audience + teacher rows, including
exact stored coverage (copyTimetableContent carries departmentId); GET
…/lessons returns activities[] filtered per view by
activitiesForView (whole-school = all, teacher = supervisor match, room =
room match, student-anchored views = roster ∩ cohort).
Diagnostics. Activities are obligations (see the catalogue note above) —
the snapshot carries activities: SnapshotActivity[] (one entry per row ×
weekday), fed by toSnapshotActivities. Each occurrence carries only teachers
whose assignment covers that weekday, so conflicts, availability, and teacher
minute budgets follow the per-day supervisor split. The out-of-band edit hole (editing a
placement's diagnostics-relevant fields is covered by mutateAndDiagnose, but
there is no definition layer to edit out-of-band — activities are self-contained
rows) does not apply here as it does for curriculum edits.
Attendance parity is the load-bearing decision — every activity behaves like
a subject group for attendance (chapter 19): the slot is a
third take anchor, and its register runs
under one attendance mode — the department's — with its own ACTIVITY
cohort admitting supervisors (ch19 §7.1).
Lesson assistance shifts¶
A ScheduledLessonAssistanceShift belongs to one version-local ScheduledLesson; its teacher junction is ScheduledLessonAssistanceTeacher. Offsets are relative to the parent; the API accepts absolute five-minute startTick/exclusive endTick. A shift must fit the effective parent duration, including an off-grid end without rounding. Parent moves carry shifts. An explicit resize that strands children rolls back; a later cascade-duration change is exposed by ASSISTANCE_OUTSIDE_LESSON.
POST/PATCH/DELETE /timetables/:id/lessons/:lessonId/assistance-shifts[/:shiftId] use the existing timetable WRITE policy and diagnostic envelope. Create requires a nonempty unique teacher set; PATCH replaces only supplied fields. PATCH {teacherIds: []} deletes the shift and returns {assistanceShift: null, diagnostics}; it cannot include interval edits. DELETE returns HTTP 200 {diagnostics}. Lesson create accepts assistanceShifts[] and deep-copies them for each requested weekday.
Lesson responses contain assistanceShifts: [] when empty. The lessons grid also exposes an independently filtered top-level collection. Each shift matches its own teachers and the parent's other facets. Its required parentLesson carries parent id, start, duration, wall bounds and the subject-group/combined-class attendance anchor, even if the ordinary lesson row is absent from a teacher-only grid. Ordinary lesson teacher arrays remain ordinary teaching assignments.
Assistance occupies only its teachers and charges every teacher its entire duration. It adds no lesson, student, room, course minutes or teaching-only daily-cap count. Teacher budget diagnostics total teaching + duty + activity + assistance; null contracts still skip the WARNING. /teachers/hour-budgets uses the same four scheduled-minute buckets; see the per-lesson selection section below. Teacher conflicts cover lesson/duty/activity/shift pairs; adjacent intervals pass. Redundancy with a regular teaching assignment is an ERROR, including later assignment windows. Cards carry assistanceShiftId and parent lessonId.
Diagnostic emissions keep the scanner's literal category, severity, variant order, including each assistance conflict pair; dynamic variant helpers hide emissions from diagnostics-cards.drift.spec.ts. The label catalogs also cover assistanceShifts, endTick and both assistance model names for localized validation/database errors.
All mutations lock the timetable then parent lesson and re-read protected geometry. Copy locks and re-reads the source before copying the complete graph. Copies/revisions/archives own fresh child ids. Teacher deletion preflights every referenced timetable, locks revision drafts before roots in deterministic lineage order, then locks teachers and rechecks references. New unprotected references produce retryable 409 TIMETABLE_TEACHER_REFERENCES_CHANGED. After the ordinary history guard, teacher links and empty shifts are removed atomically; setup full-replace shares cleanup and preflights its whole batch. Parent and timetable deletion cascade children. Publication recipients include eligible assistants once through the existing notification event. Generation emits no shifts.
Shared dated read contract: src/timetables/assistance-queries.ts is the public query-only entry point; lesson-assistance-relationships.queries.ts implements its version-owned course facts, including dated combined memberships and grade-specific teaching/calendar windows. Consumers import the query-only entry point so course/student policies do not load TimetablesModule and its reverse course dependencies. Synchronous policies consume the dated assistedCourses facts prepared at domain service boundaries. This is an ordinary course-teacher relationship; no synthetic full-lesson teaching episode is inserted.
The existing SubjectGroupTeacher and SubjectGroupAssignment junctions inherit tenant ownership through subjectGroup; assistance consumers filter that relation, not a nonexistent junction tenantId. Filter-option cascades remain structural (department/grade/curriculum/track); assistance adds teacher options from the surviving courses. Teacher selection remains on the course-table and timetable-grid contracts that actually accept it.
Per-lesson teaching selection and scheduled hour budgets¶
ScheduledLesson.teacherSelectionMode defaults to ALL. ALL stores no explicit links and inherits the dated ordinary course teaching team. SELECTED stores a version-owned ScheduledLessonTeacher set and intersects it with ordinary eligibility on the requested date. Combined eligibility uses CombinedClassMembership episodes, including pending departures; duplicate contributors collapse to one regular teacher. Empty SELECTED keeps the draft lesson. Every copy/revision/archive owns fresh child ids and preserves the mode. Teacher deletion extends the existing whole-batch assistance preflight to explicit regular references and keeps an emptied parent SELECTED. Ever-effective selected references block ordinary deletion.
POST/PATCH use teacherSelection: {mode: "ALL"} or {mode: "SELECTED", teacherIds: [...]}. Omission defaults ALL on creation and preserves selection on PATCH; null, malformed modes, duplicate UUIDs and teacherIds on ALL are invalid. An authored teacher must be eligible for remaining occurrences of that weekday, including future assignment windows. Responses expose authored teacherSelection, actual regular teachers and author-only availableTeachers (admins and curriculum coordinators) with half-open eligibility periods. Candidate/fact reads batch all returned lessons. A stale explicit id remains visible but grants no participation.
lesson-teachers.ts owns pure selection and date-range operations; lesson-teachers.queries.ts owns the lean dated query projection. Timetable teacher filters, facet options, regular occupancy and publication recipients use that rule. Planning lists retain course eligibility. Attendance applies selection after its own dated teacher reload, then unions assistants. Ordinary course permissions are unchanged.
Teacher conflicts, availability, teaching-only daily caps and assistance redundancy scan remaining assignment/member/calendar/teaching boundaries with actual weekday occurrences. LESSON_TEACHERS_MISSING and LESSON_TEACHER_SELECTION_INVALID are ERROR cards with affected ranges. Nonteacher diagnostics still see each physical lesson once. Publication resolves its effective date before final staffing checks. Same-day compatibility compares the recorded ordinary set (excluding assistance evidence) and physical anchor against incoming content; explicit-today rejects and omitted dates defer when incompatible.
teacher-commitments.ts supplies integer-minute accumulation for both budget diagnostics and GET /teachers/hour-budgets. The endpoint uses teacher-budgets.queries.ts, without loading diagnostic rosters/rooms or forward-head combined membership. Optional on defaults to school-local today. Without timetableId it selects the official effective version, including ARCHIVED history; explicit in-year timetableId is PREVIEW; no official version yields NONE and zero load. Top-level scheduleContext survives empty pages. Rows add scheduledMinutes: {teaching, assistance, activity, duty, total} and hoursAssigned = total / 60. Course labels/counts derive from scheduled regular/assisted contributions. Null contracts do not suppress load. This is a weekly-pattern snapshot, preserving existing diagnostic window treatment, not annual peak or calendar-week proration.
Shared enrolment and dated diagnostics¶
One timetable-owned engine. Domain saves never run diagnostics or compare before/after schedules. After commit they may identify official review targets; the client calls GET /timetables/:id/diagnostics. Manual mutations, diagnostic reads, publication and generation ingestion still use the same engine. Actual physical/structural ERRORs block publish; HOUR_BUDGET and TEACHER_BUDGET_EXCEEDED are warnings. Solver feasibility remains stricter where already designed; its stale-input fingerprint still protects ingestion.
Review-mark lifetime. The HTTP interceptor creates a mutable holder before TransactionHost.withTransaction. Nested inherited CLS stores share that holder; markTimetableReview updates it without replacing the CLS entry. Only the successful outer commit triggers the bounded target lookup and optional X-Timetable-Review-Ids header. Rollback discards it; a lookup failure cannot undo the saved command.
Dated facts. diagnostics-facts.queries.ts batches the selected version, teaching calendars, course windows, combination episodes, placements, course enrolments, curriculum/track selections and homeroom audiences. Lesson teaching facts supply dated regular teams and ALL/SELECTED eligibility. diagnostics-intervals.ts partitions at input boundaries, including future changes, and checks only weekdays that occur in each half-open interval. No per-day or per-student query fan-out. Existing archived versions are inspected within their retained interval; current/future reads use their remaining interval. Two versions are never compared as concurrent occupancy.
Diagnostic student eligibility. Dated fact loading applies participatingStudentWhere() to current/future planning, keeping the diagnostic roster restricted to ENROLLED just like live audience reads. Inspection of an ended version uses participationHistoryStatusWhere() alongside its dated placements: PRE_ENROLLED is always excluded, while departed students remain represented on dates they attended. Audience selectors resolve to distinct students; selecting a grade of 20 plus its two homerooms of 10 still counts 20. Break/lunch/activity POST/PATCH store selectors, never participant counts.
The projection rebuilds a combined lesson's distinct roster, actual regular teacher set and allowed-room intersection per interval. Capacity counts a student once, including three-way membership. An empty intersection of restricted room sets remains restricted. Activities and breaks re-expand their dated audience; assistance inherits its applicable parent geometry. Localized cards omit the generic "Applicable from … until …" detail line; category-specific messages retain their own date context. The engine still evaluates the same dated intervals. Weekly loads count physical weekly slots once; interval splitting does not multiply or prorate the four canonical commitment buckets.
Coverage. course-coverage.queries.ts and course-coverage.ts retain SHARED and SOLO dated provision. Shared provision is allocated only within valid combination windows; a contributor's solo requirement is its curriculum minutes minus shared minutes. Each mismatched unit produces a warning, including unplaced combined-scope units; shortage and excess do not cancel. Existing ordinary-course scope remains unchanged. Structural shared-quota, teacher-selection and degenerate-anchor checks remain separate from placed-hour warnings. Placement-plan curriculumCoverage and coverageWindows remain available.
Response. TimetableDiagnosticsDto contains timetableId, canPublish, violations and scheduleContext. There is no top-level teacherWorkloads, nor an extra all-teacher query to build it. Teacher hour views use GET /teachers/hour-budgets and its teaching/assistance/activity/duty buckets. Lesson courses[] is descriptive; it does not synthesize membership or a public teacher-relationship workflow.
Concurrency. Keep the graph lock ahead of timetable row locks, reference locks and AY-day locks for mutation, version copying, publish/unpublish/delete and ingestion. A roster save racing publication always remains eligible to save under ordinary guards. If it wins, publication evaluates that graph; if it follows a successful publish, later diagnostics can report a conflict on the published version. No automatic repair, unpublish, background evaluator or persisted unresolved flag is introduced.
Student placement commands capture their CAS fingerprint before acquiring the graph lock, while all writes remain after that lock. Capturing after serialization would let a stale move close the episode just opened by its competitor. The ordinary TEMPORAL_INTERVAL_OVERLAP concurrency refusal remains independent of timetable diagnostics.