Timetable Generation (V1)¶
Status note: all sections filled. §1.5 is the canonical reference for the Python solver service. §13 carries the full FE ↔ BE ↔ solver-service wire schemas. Awaiting user review and sign-off in §12.
1. Problem distillation¶
- The admin needs to produce a school-wide weekly draft timetable for an academic year, placing every
SubjectGroupinto PERIOD-type slots of the grade's effectiveWeekTemplate, with a room assigned, and respecting pre-assigned teachers. - A timetable is a persistent first-class entity with a lifecycle (
DRAFT→PUBLISHED). Multiple drafts may coexist per AY; at most one may bePUBLISHEDper AY at any time. - Generation is driven by a constraint solver (OR-Tools CP-SAT) running in a separate Python microservice, called synchronously by the NestJS BE.
- When generation is infeasible, the admin receives an explainable, narrative error: which constraint(s) blocked it, which entities are involved, and (when possible) which category to relax.
- After generation the admin may edit
ScheduledLessonrows freely. No hard-constraint validation runs on edits in V1 — admin discretion is trusted.
Success criteria (observable behavior that proves this works):
- Admin triggers generation against a tenant + AY → on success, a new Timetable row exists in DRAFT with N ScheduledLesson rows fully covering every grade's PERIOD slots.
- On infeasibility, the response is structured TimetableViolations with constraintCode, message, and affectedEntities. At least the common patterns produce narrative messages (rendered from the template library).
- Six views — grade, homeroom, subject group, room, teacher, student — all render the generated data without further computation.
- Admin can PATCH a lesson's roomId or (weekday, periodOrdinalPosition) without server-side validation pushback.
Non-goals (in-scope-shaped things this iteration is explicitly not doing): - Partial-locks / re-generate respecting prior assignments. - Multi-candidate generation (produce N drafts, admin picks). - Anchor-based generation (admin pins slots, solver fills rest). - Per-school soft-constraint weight tuning (V1 uses engineering-chosen hardcoded weights). - Post-edit validation of any kind (no "validate this draft" endpoint). - Publish gate (no validation enforced before publish). - Teacher/student-facing read pipelines (the six views are admin-side; deriving public-facing views from PUBLISHED timetables is a follow-up). - Async/queue-based generation (V1 sync; revisit if measured solve times exceed UX tolerance). - Soft-score surfacing to admin (the solver's objective is invisible). - Solve-time optimization beyond CP-SAT defaults (revisit when baseline runs reveal latency pressure).
1.5 Problem formalization (solver canonical reference)¶
This section is the authoritative specification of the optimization problem the Python solver service implements. The BE constructs an input snapshot conforming to §1.5.2; the solver returns assignments conforming to the variables in §1.5.3 (or a structured infeasibility result). Treat any divergence between this section and the solver implementation as a spec bug, not an implementation choice.
1.5.1 Notation conventions¶
[a..b]denotes the integer range{a, a+1, …, b}.Σ_{x ∈ X} f(x)denotes summation over the setX. WhenXis implied by context, writtenΣ_x f(x).x[i, j]is a Boolean (∈ {0, 1}) decision variable indexed by(i, j).≤,=,≥are linear constraints over Booleans/integers.- "Slot" without further qualification means teaching slot (PERIOD-type only); LUNCH/INTERVAL/NA are filtered out at the BE boundary and never enter the solver.
1.5.2 Inputs (snapshot fields the BE supplies to the solver)¶
| Symbol | Description |
|---|---|
Slots |
Set of teaching slots. Each s ∈ Slots carries: s.template, s.weekday, s.periodOrdinalPosition, s.wallStart, s.wallEnd. Wall-clock times are precomputed by the BE from DayTemplate.startTime + cumulative durations (existing computeSlotStartEndTimes helper). |
overlap(s₁, s₂) |
Boolean lookup table over Slots × Slots. True iff s₁.weekday = s₂.weekday AND the wall-clock intervals [s₁.wallStart, s₁.wallEnd) and [s₂.wallStart, s₂.wallEnd) intersect. Precomputed by the BE; the solver receives it as a flat list of overlapping pairs. |
SubjectGroups |
Set of subject groups. Each g ∈ SubjectGroups carries: g.weeklyHours, g.studentCount, g.teacherId? (nullable), g.preAssignedRoomId? (nullable), g.homeroomId, g.template (= effective week template of g's grade), g.optionBlockId? (nullable; non-null ⇒ g is an option-block child). |
OptionBlocks |
Set of option blocks. Each B ∈ OptionBlocks carries: B.weeklyHours, B.children (subset of SubjectGroups), B.homerooms (the homerooms whose students participate). Invariant: every child C ∈ B.children has C.weeklyHours = B.weeklyHours and C.optionBlockId = B.id. |
Rooms |
Set of rooms. Each r ∈ Rooms carries: r.capacity, r.departmentId? (nullable; used only for soft constraint S3). |
Teachers |
Set of teachers. Each T ∈ Teachers carries: T.availableDays (subset of {MON, …, SUN}). |
Homerooms |
Set of homerooms. Each h ∈ Homerooms carries: h.designatedRoomId? (nullable), h.template (= effective week template of h's grade). |
SlotsOf(template) |
The PERIOD-type slots of a given week template. Computed by the BE; supplied as a per-template list. |
TEACHER_DAILY_CAP |
Universal constant: 4. |
1.5.3 Decision variables¶
x[g, s] ∈ {0, 1} for g ∈ SubjectGroups, s ∈ SlotsOf(g.template)
1 iff group g occupies slot s
y[g, r, s] ∈ {0, 1} for g ∈ SubjectGroups, r ∈ CompatibleRooms(g), s ∈ SlotsOf(g.template)
1 iff group g occupies slot s in room r
b[B, s] ∈ {0, 1} for B ∈ OptionBlocks, s ∈ SlotsOf(B.template)
1 iff option block B is scheduled at slot s
(B.template = any of B.homerooms' templates; option-block homerooms
share a template — invariant enforced by the BE)
1.5.4 Compatibility pre-filter (CompatibleRooms(g))¶
Defined as a function from each subject group to its candidate room set. Variables y[g, r, s] are only created for r ∈ CompatibleRooms(g). This absorbs hard constraints H9 / H10 structurally, shrinking the model and eliminating placement-time validation.
CompatibleRooms(g):
if g.preAssignedRoomId is not null:
return { Room(g.preAssignedRoomId) } if room.capacity ≥ g.studentCount else ∅
return { r ∈ Rooms : r.capacity ≥ g.studentCount }
Note: H11 (homeroom designated room) was previously absorbed here but is now dropped as a hard constraint (see §1.5.5 row H11 and §1.5.6 soft S10). Mandatory subject groups can use any capacity-sufficient room; the homeroom's designated room is only a soft preference.
Cases where CompatibleRooms(g) = ∅ are caught by pre-checks #2 and #3 (§1.5.7) before the solver runs.
1.5.5 Hard constraints¶
Each constraint carries a category label (used by the unsat-core renderer in §1.5.8) and a per-instance entity reference (also for rendering). The labels are stable identifiers; renaming them is a breaking change to the violation template library.
| # | Label | In plain language | Formal statement | Per-instance entities |
|---|---|---|---|---|
| H1 | HOUR_BUDGET |
Each subject group is taught exactly the number of hours per week that its study plan requires. | ∀ g ∈ SubjectGroups: Σ_{s ∈ SlotsOf(g.template)} x[g, s] = g.weeklyHours |
{ subjectGroupId: g.id } |
| H2 | SAME_DAY_CONTIGUITY |
When a subject has more than one period on the same day, no other lesson sits between them. Intervals and lunch breaks may fall between them — those are part of the day's structure, not "other lessons". | ∀ g, ∀ weekday d: the 1-positions of (x[g, s])_{s on day d}, ordered by s.periodOrdinalPosition, form a contiguous segment. Slots is PERIOD-only by §1.5.2 — non-PERIOD slots are filtered at the BE boundary and never enter this ordering, so "contiguous" automatically means contiguous in the PERIOD-only sequence. |
{ subjectGroupId: g.id, weekday: d } |
| H3 | TEMPLATE_ALIGNMENT |
A subject group is only ever placed in time slots that exist in its grade's week template. | Implicit by domain definition — variable x[g, s] is only created for s ∈ SlotsOf(g.template). No solver constraint needed. |
n/a (structural) |
| H4 | HOMEROOM_NO_GAP |
Every period of every homeroom's week is filled with a lesson. Students never have a free hour in the middle of their day. | ∀ h ∈ Homerooms, ∀ s ∈ SlotsOf(h.template): Σ_{g mandatory of h} x[g, s] + Σ_{B : h ∈ B.homerooms} b[B, s] = 1 |
{ homeroomId: h.id, slot: s } |
| H5 | TEACHER_CONFLICT |
A teacher is never scheduled to teach two different lessons at the same time. | ∀ T ∈ Teachers with assigned groups, ∀ (s₁, s₂) with overlap(s₁, s₂) = true: Σ_{g : g.teacherId = T} (x[g, s₁] + x[g, s₂]) ≤ 1 |
{ teacherId: T.id, slots: [s₁, s₂] } |
| H6 | TEACHER_DAILY_CAP |
No teacher teaches more than 4 periods in a single day. | ∀ T ∈ Teachers, ∀ weekday d: Σ_{g : g.teacherId = T} Σ_{s on day d in SlotsOf(g.template)} x[g, s] ≤ TEACHER_DAILY_CAP |
{ teacherId: T.id, weekday: d } |
| H7 | TEACHER_AVAILABILITY |
A teacher is only scheduled on the weekdays they're contractually available. | ∀ g with g.teacherId = T, ∀ s on day d ∉ T.availableDays: x[g, s] = 0 |
{ teacherId: T.id, weekday: d, subjectGroupId: g.id } |
| H8 | ROOM_CONFLICT |
A room never hosts two different lessons at the same time. | ∀ r ∈ Rooms, ∀ (s₁, s₂) with overlap(s₁, s₂) = true: Σ_{g} (y[g, r, s₁] + y[g, r, s₂]) ≤ 1 |
{ roomId: r.id, slots: [s₁, s₂] } |
| H9 | ROOM_CAPACITY |
Every group is placed in a room large enough to hold all its students. | Absorbed into CompatibleRooms(g) pre-filter. |
n/a (structural) |
| H10 | PRE_ASSIGNED_ROOM |
If a subject group has been pinned to a specific room in advance, it is scheduled there. | Absorbed into CompatibleRooms(g) pre-filter. |
n/a (structural) |
| H11 | ~~HOMEROOM_ROOM_CONSISTENCY~~ |
Dropped as a hard constraint (chat 2026-05-26). Some mandatory subjects — gym, music, lab — inherently use specialized rooms different from the homeroom's classroom, but we don't yet model the subject↔specialized-room link in the data layer. Forcing homeroom-room for all mandatory groups would make those subjects infeasible. Replaced by soft S10 (HOMEROOM_DESIGNATED_ROOM_PREFERENCE), which nudges placements toward the designated room without forbidding deviations. Revisit when the subject↔room link lands (see §9 Deferrals). |
Removed. | n/a |
| H12 | OPTION_BLOCK_SYNC |
All the alternative subjects in an option block (e.g. English / French / Spanish in a "second foreign language" block) are scheduled at the same time, so each student can pick one without clashing with anything else. | ∀ B ∈ OptionBlocks, ∀ C ∈ B.children, ∀ s ∈ SlotsOf(B.template): x[C, s] = b[B, s] |
{ optionBlockId: B.id, childSubjectGroupIds: [C.id, …] } |
| H13 | SAME_GROUP_SAME_DAY_SAME_ROOM |
When a subject has back-to-back periods on the same day (a "double"), both periods happen in the same room — no moving the class mid-lesson. | ∀ g, ∀ weekday d, ∀ s₁, s₂ on day d with s₁ ≠ s₂, ∀ r ∈ CompatibleRooms(g): (x[g, s₁] = 1 ∧ x[g, s₂] = 1) ⇒ (y[g, r, s₁] = y[g, r, s₂]) |
{ subjectGroupId: g.id, weekday: d } |
| H14 | X_Y_COUPLING |
Every scheduled lesson has exactly one room: no lesson without a room, no lesson in two rooms at once. (Internal consistency, not user-facing.) | ∀ g, ∀ s ∈ SlotsOf(g.template): Σ_{r ∈ CompatibleRooms(g)} y[g, r, s] = x[g, s] |
n/a (model integrity, never appears in unsat cores by design — drop from violation renderer) |
1.5.6 Soft constraints (objective)¶
The solver minimizes a weighted sum of penalty terms. Weights are hardcoded in V1 (engineering-chosen, no per-school config). All weights are positive integers; smaller penalty = better solution.
| # | Label | In plain language | Penalty definition | Default weight |
|---|---|---|---|---|
| S1 | TEACHER_DAY_SPAN |
Try not to spread a teacher's lessons across the whole day. Avoid e.g. first-period + last-period with nothing in between. | ∀ T, ∀ weekday d where T teaches ≥1 period: span_{T,d} = (last_period_index − first_period_index + 1); penalty_{S1} = Σ_{T, d} span_{T,d} |
TBD (calibrate during solver bring-up) |
| S2 | DOUBLES_PREFERENCE |
For subjects with many hours per week, prefer combining some hours into back-to-back "double" periods instead of spreading them as single periods every day. | For each (g, d) where g.weeklyHours ≥ HIGH_HOURS_THRESHOLD (default 4) and the day's block has length ℓ: reward (negative penalty) on ℓ ≥ 2. Concretely: penalty_{S2} = −Σ_{(g,d) eligible} max(0, ℓ_{g,d} − 1) |
TBD |
| S3 | DEPARTMENT_ROOM_PREFERENCE |
When a lesson's room isn't already pinned, prefer using a room that belongs to that subject's department (e.g. science labs for science). | ∀ g with g.preAssignedRoomId = null AND g.homeroom.designatedRoomId = null, ∀ (r, s) with y[g, r, s] = 1: penalty if r.departmentId ≠ g.subject.departmentId |
TBD |
| S4 | HOMEROOM_ROOM_STABILITY |
When a homeroom doesn't have its own classroom, try to keep its weekly lessons in as few different rooms as possible — less moving around. | ∀ h with h.designatedRoomId = null: penalty = (number of distinct rooms used by h's mandatory groups across the week) − 1 |
TBD |
| S6 | EVEN_WEEKLY_DISTRIBUTION |
Spread each subject's hours evenly across the week. Avoid e.g. "all 5 hours of math on Monday". | ∀ g: penalty = max_d(hours of g on day d) − min_d(hours of g on day d among active days) |
TBD |
| S8 | TEACHER_PRESENCE |
Prefer teachers being present in school across as many days as possible — 2 hours each day across 4 days is better than 6 hours across 2 days. Schools want teachers around between lessons, not concentrated and then absent. (This is the inverse of an intuitive "minimize working days" framing — schools genuinely prefer the opposite per chat 2026-05-26.) | ∀ T: penalty = (number of weekdays d ∈ T.availableDays where T teaches 0 periods) — i.e., count of available-but-unused days per teacher. |
TBD |
| S9 | SOLO_ISOLATED_PERIODS |
Avoid scheduling a teacher for just one period on a given day — saves them coming in for a single hour. Composes with S8: prefer being present many days, but with at least 2 periods per present day. | ∀ T, ∀ weekday d: penalty = 1 if T teaches exactly 1 period on d, else 0 |
TBD |
| S10 | HOMEROOM_DESIGNATED_ROOM_PREFERENCE |
When a homeroom has its own classroom, prefer placing its mandatory lessons there — but allow specialized subjects (gym, music, lab) to use their natural rooms instead. Soft because we can't yet distinguish specialized subjects in the data layer. | ∀ g mandatory of h where h.designatedRoomId is not null, ∀ (r, s) with y[g, r, s] = 1: penalty if r ≠ h.designatedRoomId |
TBD |
(S5 / S7 from the brainstorm were dropped / promoted; numbering preserved for traceability with the brainstorm transcript.)
Weight calibration: the solver service ships with placeholder weights; calibrate against a fixture school after the first end-to-end runs. Calibration is part of the V1 acceptance criteria, not a follow-up — but the calibration values are not specified by this document.
1.5.7 Pre-checks (BE-side, run before invoking the solver)¶
These run in the NestJS BE, not in the solver service. They produce highly specific violation messages — strictly better than anything we can recover from a post-solve unsat core. Order: short-circuit on the first failure category that produces ≥1 violation, OR run them all and bulk-report (TBD in contracts pass).
| # | Label | In plain language | Predicate | Failure message template |
|---|---|---|---|---|
| 1 | TEACHER_CAPACITY |
A teacher's total assigned weekly hours can't exceed what they can physically teach: 4 periods/day across the weekdays they're available. | ∀ T : Σ(g.weeklyHours for g where g.teacherId = T) ≤ Σ_{d ∈ T.availableDays} min(TEACHER_DAILY_CAP, count(SlotsOf(g.template on day d))) (aggregated across the templates the teacher's groups span) |
"Teacher {name} is assigned {N} hours but can teach at most {M} (across {K} available days at 4 periods/day max)." |
| 2 | ROOM_CAPACITY_MAX |
Every subject group must have at least one room in the school big enough to hold it. | ∀ g : max(r.capacity for r ∈ Rooms) ≥ g.studentCount |
"Subject group {name} has {N} students; no room in the school has capacity for this." |
| 3 | PRE_ASSIGNED_ROOM_CAPACITY |
If a subject group has been pinned to a specific room, that room has to be big enough for it. | ∀ g with g.preAssignedRoomId = R : R.capacity ≥ g.studentCount |
"Subject group {name} ({N} students) is pre-assigned to room {room} ({capacity})." |
| 4 | OPTION_BLOCK_TEACHER_DISTINCTNESS |
The alternative subjects inside one option block can't share a teacher — they run at the same time, so one teacher can't cover two of them. | ∀ B ∈ OptionBlocks : the multiset {C.teacherId : C ∈ B.children ∧ C.teacherId ≠ null} has no duplicates |
"Option block '{name}' has two children taught by the same teacher ({teacher}); they cannot run simultaneously." |
| 5 | OPTION_BLOCK_HOUR_CONSISTENCY |
Within an option block, every alternative subject must have the same weekly hours as the block itself. | ∀ B ∈ OptionBlocks, ∀ C ∈ B.children : C.weeklyHours = B.weeklyHours |
"Option block '{name}' ({block_hours}h) has a child {child_name} with {child_hours}h." |
Pre-check 5 is also a data-layer invariant (option-block schema enforces it on write). The check here is defence-in-depth.
1.5.8 Solver tech + infeasibility handling¶
- Solver: OR-Tools CP-SAT (Python). Native interval / no-overlap primitives, mature Boolean handling, assumption-based unsat-core extraction.
- Statelessness contract (per chat 2026-05-26): the solver service is a one-shot stateless RPC. BE sends one snapshot, gets one response. The entire infeasibility pipeline below — main solve + minimization + optional relaxation — happens inside the single solver-service call. The BE never observes intermediate iterations and never holds CP-SAT state between calls.
- Constraint labeling: every hard constraint instance carries
(category, entityRefs). In CP-SAT this is implemented by tagging each constraint with a Boolean assumption literal; assumption labels are returned in the core when infeasible. - Infeasibility pipeline (when the initial solve returns INFEASIBLE — all of this is internal to the solver service):
- Extract the raw unsat core (set of assumption literals).
- Minimize it: iteratively try dropping each literal and re-solve; keep dropping while still infeasible. Cost:
O(|core|)internal solver iterations, typically <20. - Map each surviving literal back to its
(category, entityRefs). - Return the labeled minimal core to the BE for template-library rendering (the BE owns the renderer; the solver returns structured labels, not rendered messages).
- Optional relaxation diagnostic: re-solve with each category in the minimal core relaxed independently; report which single-category relaxation restores feasibility. V1 ships without this pass; add as a follow-up if production messages prove vague.
- Termination defaults:
- Wall-clock budget:
60sfor the whole solver-service call (initial solve + minimization + any optional diagnostic passes), not per-iteration. If the budget exhausts mid-minimization, the solver returns the best-minimized core it has — still useful, just less specific. - Solution acceptance: best-found-within-budget if proven optimal would exceed budget. Optimality gap: CP-SAT default.
- Determinism: fixed random seed in V1 for reproducible outputs across re-runs on identical input.
1.5.9 Sizing (rough order-of-magnitude)¶
For a medium school: 60 subject groups, ~30 teaching slots/week per template, 3 templates, 25 rooms.
x: ~60 × 30 = 1.8k Booleansy: ~60 × 20 (avg CompatibleRooms after pre-filter; H11-drop means most groups can use most rooms — only capacity restricts) × 30 = ~36k Booleansb: ~10 option blocks × 30 = 300 Booleans
Total ~38k Booleans + soft-objective auxiliary vars (linear in instances). Comfortably within CP-SAT's solving range; solve times expected to be seconds-to-low-minutes in V1. (The 4x growth in y from the original 9k estimate is a consequence of dropping H11 as a hard constraint; revisit when the subject↔specialized-room link lands — see §9 Deferrals.)
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/timetable-templates/ + 2026-05-25 timetable-templates spec |
The full template stack (DayTemplate/WeekTemplate/TimeSlot/effective assignment resolution) is our input model. We reuse resolveEffectiveAssignment, dayTemplateDetailInclude, weekTemplateDetailInclude and the loadAssignmentTreeForAcademicYear helper for snapshot construction. |
Templates are configuration; timetables are runtime artifacts. Lifecycle is different (templates are tenant-flat; timetables are AY-scoped with status). |
src/curriculum/ (study plans + option blocks) + 2026-05-26 option-block-subject-merge spec |
OptionBlock schema is in place and supplies the synchronized-placement constraint. We treat each block as a virtual "super-group" in the solver model (one b[B,s] boolean coupled to all children's x[Cᵢ,s]). |
Study plans are static configuration; we only consume them. |
src/teachers/ (teacher availability data, US-37.1 ✔️) |
Teacher contract availability days are an input to H7 (TEACHER_AVAILABILITY) and to pre-check #1 (TEACHER_CAPACITY). Already-shipped per docs/epics.md Epic 10. |
Availability is read-only input for us; we don't shape its model. |
src/subject-groups/ + 2026-05-25 us33/us33.1 spec |
SubjectGroup is the primary input. We follow the eligible-students/picker conventions (see [[reference_eligible_students_picker_pattern]]) for any UI selectors the timetable views might need. |
Subject groups are roster containers; timetables coordinate their temporal placement — a different concern that consumes them. |
src/academic-years/ |
AY-scoped lifecycle conventions: status enum + transitions, single-active invariant per tenant via partial-unique index. Timetable's (tenantId, academicYearId) WHERE status='PUBLISHED' index follows the same pattern as AY's (tenantId) WHERE status='ACTIVE'. |
AY lifecycle has more states (DRAFT/ACTIVE/CLOSED); timetable V1 has only DRAFT/PUBLISHED. |
src/command-center/ + 2026-05-05 command-center spec |
Admin dashboard surface conventions for an AY-anchored aggregate view. Generation triggering UI may sit alongside command-center as a sibling admin tool. | Command center is read-only aggregation; timetable generation is a mutation with a long-running compute. |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | tenantId denormalized onto both Timetable and ScheduledLesson; every query filters by it via the standard middleware. |
Same multi-tenant invariant as every domain entity. |
| Academic-year scope | yes | Timetable.academicYearId (FK). ScheduledLesson inherits AY via timetable; no separate column. |
Generation is per-AY; multi-AY listings filter by AY. |
| RBAC entity key | new — TIMETABLES |
Add to src/common/constants/entity-keys.ts. |
Distinct domain concept; not a sub-resource of any existing entity. |
| Scopes | 'configuration' |
Single field-group scope on TIMETABLES, matching the 'configuration' convention used by DEPARTMENTS, ROOMS, SCHOOL. Writes are gated by @RequireScopes(TIMETABLES, 'write') per [[feedback_rbac_no_update_action]]; reads use @RequireScopes(TIMETABLES, 'configuration'). |
Admin write, all-roles read on the whole entity per chat 2026-05-26 — no per-user field-level subdivision needed. |
| Actions | 'generate', 'publish' |
Two custom actions. 'generate': admin only. 'publish': admin only. Plain reads/writes use scope-only. |
Both are non-CRUD state transitions that deserve explicit action keys for audit/seed clarity. |
| Service base | custom — not BaseTenantedCrudService |
The trigger workflow (snapshot → HTTP to solver → ingest result) is not a standard CRUD shape. Manual edits are CRUD-shaped and may reuse helpers. | The dominant operation is the generation pipeline; CRUD is incidental. |
queries.ts shape |
yes — *Include consts + named functions per [[reference_codebase_map]] convention |
Includes: timetableDetailInclude (lessons + subject group + room), lessonForViewInclude (per-view projections). Functions: loadTimetableOrThrow, loadLessonsForView(view, targetId), buildGenerationSnapshot(tenantId, ayId). |
Standard convention; no precedent for deviating. |
| Error codes | new | See §4 Error codes below. | Each new failure mode gets its own typed code. |
| DTO conventions | standard | CreateTimetableDto, UpdateLessonDto, TimetableResponseDto, ScheduledLessonResponseDto, view-specific projection DTOs. |
Standard. |
| File-backed sub-resources | n/a — no files attached to timetables. | ||
| Custom fields | n/a — runtime artifact, not configurable. | ||
| Profile completeness | n/a — not a person entity. |
4. Data model plan¶
Schema deltas¶
enum TimetableStatus {
DRAFT
PUBLISHED
}
model Timetable {
id String @id @default(uuid())
tenantId String
academicYearId String
name String // admin-provided; default "Draft <timestamp>"
status TimetableStatus @default(DRAFT)
generatedAt DateTime? // null if generation failed or hasn't completed
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id])
academicYear AcademicYear @relation(fields: [academicYearId], references: [id])
lessons ScheduledLesson[]
@@index([tenantId, academicYearId])
}
model ScheduledLesson {
id String @id @default(uuid())
tenantId String
timetableId String
subjectGroupId String
weekday DayOfWeek // existing Prisma enum
periodOrdinalPosition Int // 1..n; service-layer guard ensures it points to a PERIOD slot
roomId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
timetable Timetable @relation(fields: [timetableId], references: [id], onDelete: Cascade)
subjectGroup SubjectGroup @relation(fields: [subjectGroupId], references: [id])
room Room @relation(fields: [roomId], references: [id])
@@unique([timetableId, subjectGroupId, weekday, periodOrdinalPosition])
@@index([tenantId, timetableId])
@@index([tenantId, subjectGroupId])
@@index([tenantId, roomId, weekday, periodOrdinalPosition])
}
Notable model decisions:
- Slot coordinate is
(weekday, periodOrdinalPosition), nottimeSlotId. The TimeSlot row is resolved at read time viasubjectGroup → grade → effectiveWeekTemplate → day[weekday].dayTemplate → slots[periodOrdinalPosition]. Rationale: stable across slot reorderings, smaller foreign-key surface, follows the same shape ScheduledLesson coordinates take in solver output. The PERIOD-type invariant is enforced at write time by the service, not at the DB level. tenantIddenormalized ontoScheduledLessonfor query performance (room-view, subject-group-view, teacher-view all filter by tenant + a single secondary index).- No
teacherIdon ScheduledLesson. Teacher is read throughsubjectGroup.teacherId— if a teacher reassignment happens post-publish, the timetable view picks it up automatically; ditto for "teacher TBD" subject groups. - No
optionBlockIdon ScheduledLesson. Option-block placements are derived: multiple lessons sharing(timetableId, weekday, periodOrdinalPosition)whose subject groups link to the same OptionBlock.
Migration shape¶
- Additive. Two new tables, one new enum. No existing column changes.
- Data backfill: none — purely new entities.
- Hazards from chapter 12 checklist:
CascadeonScheduledLesson.timetableId: intentional. Deleting a Timetable removes its lessons. No external consumers of lesson rows yet.- Partial unique index
(tenantId, academicYearId) WHERE status='PUBLISHED'requires a raw SQL stanza in the migration (Prismaschema.prismadoesn't express partial unique). Follow the same pattern asAcademicYear.status='ACTIVE'.
Indexes and uniqueness¶
Timetable:- Btree
(tenantId, academicYearId)— list-by-AY queries. - Partial unique
(tenantId, academicYearId)WHEREstatus = 'PUBLISHED'— DB-level "at most one PUBLISHED per AY per tenant". ScheduledLesson:- Btree
(tenantId, timetableId)— list lessons of a timetable. - Btree
(tenantId, subjectGroupId)— subject-group view. - Btree
(tenantId, roomId, weekday, periodOrdinalPosition)— room view. - Unique
(timetableId, subjectGroupId, weekday, periodOrdinalPosition)— same group can't be at the same slot twice.
5. API surface¶
Endpoints¶
| # | Verb | Path | Decorators | Request DTO | Response |
|---|---|---|---|---|---|
| 1 | POST |
/timetables |
@RequireAction(TIMETABLES, 'generate') |
GenerateTimetableDto |
201 TimetableResponseDto (success) or 422 TimetableGenerationErrorDto (pre-check failure / solver infeasibility / solver timeout) |
| 2 | GET |
/timetables |
@RequireScopes(TIMETABLES, 'configuration') |
AcademicYearQueryDto (query string) |
200 TimetableListResponseDto — no pagination in V1 |
| 3 | GET |
/timetables/:id |
@RequireScopes(TIMETABLES, 'configuration') |
— | 200 TimetableResponseDto (metadata only; lessons fetched via #5) |
| 4 | PATCH |
/timetables/:id |
@RequireScopes(TIMETABLES, 'write') |
UpdateTimetableDto (V1: name only) |
200 TimetableResponseDto |
| 5 | GET |
/timetables/:id/lessons?view={grade\|homeroom\|subject-group\|room\|teacher\|student}&targetId=:id |
@RequireScopes(TIMETABLES, 'configuration') |
TimetableViewQueryDto (view, targetId) |
200 TimetableLessonsViewDto — uniform shape across all six views |
| 6 | POST |
/timetables/:id/lessons |
@RequireScopes(TIMETABLES, 'write') |
CreateScheduledLessonDto |
201 ScheduledLessonResponseDto |
| 7 | PATCH |
/timetables/:id/lessons/:lessonId |
@RequireScopes(TIMETABLES, 'write') |
UpdateScheduledLessonDto |
200 ScheduledLessonResponseDto |
| 8 | DELETE |
/timetables/:id/lessons/:lessonId |
@RequireScopes(TIMETABLES, 'write') |
— | 204 No Content |
| 9 | PUT |
/timetables/:id/status |
@RequireAction(TIMETABLES, 'publish') |
UpdateTimetableStatusDto (status) |
200 TimetableResponseDto or 409 TIMETABLE_PUBLISH_CONFLICT (another timetable in the same AY is already PUBLISHED — admin must demote it first) |
| 10 | DELETE |
/timetables/:id |
@RequireScopes(TIMETABLES, 'write') |
— | 204 No Content (cascades to lessons via FK) |
Validation discipline on lesson CRUD (#6, #7): per chat 2026-05-26, no constraint-checking validation runs. The admin can create / move / delete lessons freely — including placements that violate hard constraints (overlapping teacher, gap in homeroom, room overflow, etc.). The BE only enforces:
- Tenant-scope FK checks on
subjectGroupId/roomId(security floor — cross-tenant FK is a data leak, not a validation). - DB-level coordinate uniqueness
(timetableId, subjectGroupId, weekday, periodOrdinalPosition)— P2002 translated to409 SCHEDULED_LESSON_COORDINATE_CONFLICT.
Everything else (constraint checks, period-is-PERIOD-type check, room capacity, etc.) is intentionally absent in V1.
Key DTO shapes (summary — full wire schemas in §13)¶
GenerateTimetableDto—{ name?: string, academicYearId?: string }. Defaults: name ="Draft <ISO timestamp>", academicYearId = active AY.TimetableResponseDto—{ id, name, academicYearId, status, generatedAt, createdAt, updatedAt, lessonCount, solveWallClockMs? }.TimetableListResponseDto—{ items: TimetableResponseDto[], total: number }.TimetableGenerationErrorDto—{ outcome: 'PRECHECK_FAILED' | 'SOLVER_INFEASIBLE' | 'SOLVER_TIMEOUT', violations: TimetableViolation[], solveWallClockMs? }. Polymorphic withTimetableResponseDtoon thePOST /timetablesresponse — discriminated by HTTP status (201 vs. 422).TimetableViolation—{ source: 'PRECHECK' | 'SOLVER', category: string, message: string, entities: Record<string, unknown> }.categorymatches the label set in §1.5.5 / §1.5.7.messageis pre-rendered by the BE's template library.entitiesis denormalized (includes display names alongside IDs) for richer FE rendering; freeform shape per category in V1.TimetableLessonsViewDto—{ timetableId, view, targetId, target: {id, displayName}, slotGrids: SlotGrid[], lessons: ScheduledLessonView[] }. Single-template views (grade / homeroom / subject-group / student) carry oneslotGrid; cross-template views (teacher / room) may carry multiple.ScheduledLessonResponseDto/ScheduledLessonView—{ id, timetableId, subjectGroup: {id, displayName, subjectName}, weekday, periodOrdinalPosition, wallStart, wallEnd, room: {id, displayName}, teacher: {id, displayName} | null, homeroom: {id, displayName}, optionBlock: {id, displayName} | null, createdAt, updatedAt }.CreateScheduledLessonDto—{ subjectGroupId, weekday, periodOrdinalPosition, roomId }.UpdateScheduledLessonDto—{ weekday?, periodOrdinalPosition?, roomId? }.UpdateTimetableDto—{ name?: string }.UpdateTimetableStatusDto—{ status: 'DRAFT' | 'PUBLISHED' }.TimetableViewQueryDto—{ view: 'grade' | 'homeroom' | 'subject-group' | 'room' | 'teacher' | 'student', targetId: string }.AcademicYearQueryDto— reuses the existing shared DTO ({ academicYearId?: string }).
Error codes (new)¶
| Code | HTTP | When |
|---|---|---|
TIMETABLE_NOT_FOUND |
404 | :id doesn't resolve in tenant |
TIMETABLE_PUBLISH_CONFLICT |
409 | PUT status='PUBLISHED' when another is already PUBLISHED in the same AY |
TIMETABLE_GENERATION_PRECHECK_FAILED |
422 | Generation request: ≥1 pre-check produced a violation |
TIMETABLE_GENERATION_SOLVER_INFEASIBLE |
422 | Generation request: solver returned INFEASIBLE |
TIMETABLE_GENERATION_SOLVER_TIMEOUT |
422 | Generation request: solver returned TIMEOUT |
TIMETABLE_GENERATION_SOLVER_UNREACHABLE |
502 | BE could not reach the solver service (network, auth, internal error response) |
SCHEDULED_LESSON_NOT_FOUND |
404 | :lessonId doesn't resolve under the parent timetable |
SCHEDULED_LESSON_COORDINATE_CONFLICT |
409 | POST / PATCH would violate (timetableId, subjectGroupId, weekday, periodOrdinalPosition) uniqueness |
The four TIMETABLE_GENERATION_* 422 codes share the same response shape (TimetableGenerationErrorDto); the code disambiguates the outcome field for clients that prefer code-based dispatch.
Publish semantics¶
- Per chat 2026-05-26: no auto-demote.
PUT /timetables/:id/statusto'PUBLISHED'fails with409 TIMETABLE_PUBLISH_CONFLICTif any other timetable in the same AY is currentlyPUBLISHED. The admin must explicitly demote the existing one toDRAFTfirst (separatePUT … 'DRAFT'). - The DB partial-unique index is the ground-truth enforcement; the service layer catches the resulting
P2003/P2002and translates to the typedTIMETABLE_PUBLISH_CONFLICTerror code with the blocking timetable's id in the error params. - FE is expected to gate the publish action behind a "you have another published timetable, demote it first?" confirmation, but the BE does not assume that gate is in place.
Swagger considerations¶
- Response polymorphism on
POST /timetables: success returns aTimetableResponseDto(201); failure returnsTimetableGenerationErrorDto(422). Render as discriminated by HTTP status, with each status carrying its own typed response schema. Theoutcomefield on the 422 body is an additional discriminator for clients that prefer body-based dispatch. GET /timetables/:id/lessonsis a single endpoint discriminated by theviewquery enum. Response is a uniformTimetableLessonsViewDto— same shape across all six views — so the FE select-dropdown rendering is straightforward. (Per chat 2026-05-26: query-discrimination was chosen specifically because the FE picks views via a select, and a stable URL with changing query is friendlier than swapping the URL path segment.)TimetableViolation.entitieshas a freeform shape percategoryin V1. Documented asRecord<string, unknown>in Swagger with a note that FE strictly needs onlymessage. Future iterations may tighten to a discriminated union per category.lessonCountandsolveWallClockMsonTimetableResponseDto— both informational fields.solveWallClockMsis populated on every Timetable in V1 since the only creation path is the generator endpoint.
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
New: (TIMETABLES, 'configuration'). |
PermissionAction (rbac-catalogue.ts) |
New: (TIMETABLES, 'generate'), (TIMETABLES, 'publish'). |
ScopeFieldMapping (rbac-catalogue.ts) |
None — single field-group scope. |
| Role grants (roles.ts) | ADMIN: (TIMETABLES, 'configuration') (read) + write access on PATCH endpoints via @RequireScopes('write') + (TIMETABLES, 'generate') + (TIMETABLES, 'publish'). All other roles: (TIMETABLES, 'configuration') only (read). |
*_SCOPES runtime constant |
Add TIMETABLES_SCOPES = ['configuration'] to src/common/constants/scope-fields.ts per [[feedback_rbac_drift_check]]. |
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
| Monolith Nest+Prisma: every business operation runs in-process. | Generation runs in a separate Python microservice invoked via sync HTTP. V1 deployment: co-located with the BE in the same deployment unit (internal DNS resolution, no public-network hop). V1 authn: shared bearer secret in env vars (TIMETABLE_SOLVER_SECRET) on both sides — BE sends Authorization: Bearer $TIMETABLE_SOLVER_SECRET, solver compares against its own env. Cheap to rotate; trivially upgradable to mTLS later if the service moves out. |
OR-Tools CP-SAT has no production-quality Node binding; Python is the canonical client. The constraint solver is the only viable solver tech for the problem class with explainable infeasibility. Co-location + shared-secret authn minimize ops surface in V1. | New deployment surface (a Python service to run, monitor, version). Acceptable — solver complexity is irreducible; co-location absorbs most of the ops cost. |
| Sync HTTP for short ops, async/queue for long ops (typical industry convention; we don't have a hard rule yet). | V1 keeps generation sync even though solve times may exceed typical sync budgets. | Defer optimization until baseline runs reveal actual latencies. Async + queue is a follow-up if measured pressure warrants it. | Admin may wait on a long request. Acceptable for V1; revisit when timed in real usage. |
| Validation at write or at publish (existing pattern: status transitions and publish-like operations validate the entity). | V1 has zero validation on ScheduledLesson edits and on the PUBLISH transition. |
Per chat 2026-05-26: admin discretion is trusted; complexity reduction. | Published timetable can contain hard-constraint violations introduced by manual edits. Revisit when we wire teacher/student-facing views and need invariants. |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| "Generate a draft of the timetable" — open-ended on workflow. | Could imply interactive, multi-candidate, or partial-lock workflows that explode V1 scope. | V1: one-shot generation only; re-run discards prior draft (admin creates a new Timetable). | Resolved (chat 2026-05-26). |
| "Allocate a slot for each subject group … each interval and lunch shift." | Intervals + lunch shifts are already locked in the WeekTemplate (LUNCH/INTERVAL slot types carry their resources). | Solver only places PERIOD-type slots. LUNCH/INTERVAL are pre-allocated and not solver decisions. | Resolved (chat 2026-05-26). |
| Implicit: hard constraints on edits ("admin shouldn't be able to break the timetable"). | Edit validation is non-trivial and V1 wants to ship a working generator. | V1: no edit validation. Admin is trusted. Revisit when teacher/student-facing views land. | Resolved (chat 2026-05-26). |
| Implicit: a single timetable per AY. | Useful to keep prior drafts for comparison or revert. | N timetables per AY, ≤1 PUBLISHED. | Resolved (chat 2026-05-26). |
| "Teacher contracts define hours they can teach weekly + hours they actually do." | This sounds like a hard constraint (teacher overcommit). | Treated as informational, not enforced — teacher-school agreements aren't fully modeled, admin overrides allowed. Per-day cap (4 periods/day) is enforced. | Resolved (chat 2026-05-26). |
| Implicit: room change minimization as a soft constraint. | Ambiguous as stated; for whom (teacher? homeroom?). | Decomposed into specific soft constraints: teacher room stability, homeroom room stability (when no designated room), department-room preference. Same-group consecutive periods in same room is promoted to a HARD constraint (H13). | Resolved (chat 2026-05-26). |
| Implicit: morning preference for "hard" subjects. | Pulls in subject-difficulty modeling the data layer doesn't have. | Dropped for V1. Out of scope. | Resolved (chat 2026-05-26). |
| US-38 (docs/epics.md): "publish gate" and "blocking-vs-non-blocking warnings". | Publish gate requires post-edit validation, which V1 defers. Blocking-vs-non-blocking warnings imply a multi-tier violation classification we don't yet have. | V1 publishes with no validation gate (already covered in Divergence ledger). Blocking-vs-non-blocking distinction collapses to the single hard/soft split we already use. Revisit when post-edit validation lands. | Resolved (chat 2026-05-26). |
9. Deferrals¶
- Partial-lock + re-generate — preserving admin-pinned slots across regeneration. Revisit when admins report regenerate is painful in practice.
- Multi-candidate generation — produce N drafts, admin compares and picks. Strict scope creep for V1.
- Anchor-based generation — admin pins anchors before generation. Same.
- Per-school soft-constraint weight tuning — V1 hardcodes weights. Revisit if schools push back on placement quality.
- Post-edit validation — full hard-constraint check on edited drafts. Revisit when teacher/student-facing views land and need invariants.
- Publish gate — enforce zero violations before allowing publish. Same trigger as post-edit validation; co-revisit.
- Soft-score reporting — surface objective score and per-constraint breakdown to admin. Revisit when admins ask "why this one over that one".
- Async/queue-based generation — move off sync HTTP to a job + notification model. Revisit when measured solve times exceed UX tolerance (~30s).
- Solve-time optimization — CP-SAT tuning, parallel solve, partial-solution acceptance. Defer until baseline runs reveal latency pressure.
- Teacher/student-facing read pipelines — derive public views from PUBLISHED timetables with their own RBAC and rendering. Separate iteration.
- Notifications on publish — alert teachers/students when their schedule lands. Separate iteration.
- Subject↔specialized-room link — extend the data layer so a Subject (in Curriculum) can declare a specialized room (gym hall, music room, lab). Would let us re-introduce H11 as a conditional hard constraint ("mandatory groups go in the homeroom's designated room unless the subject has its own specialized room"), shrinking
CompatibleRooms(g)and speeding up the solver. V1 ships without this — S10 soft preference is the workaround. Revisit when the data model can carry the link. - Relaxation diagnostic pass (optional in Topic B) — additional solver passes that relax categories one at a time to disambiguate. Ship V1 with minimized unsat core only; add relaxation if production messages prove too vague.
Follow-up: most of the above will surface as memory entries (project_*.md) at sign-off so the next iteration can find them.
10. Open questions¶
All must be resolved before sign-off.
None — all resolved. Resolution trail (chat 2026-05-26):
- Scope key name →
'configuration'(matches the convention used byDEPARTMENTS,ROOMS,SCHOOL). - Views endpoint shape → query-discriminated single endpoint (
GET /timetables/:id/lessons?view=...&targetId=...), uniform response shape; chosen because the FE picks views via a select. - PUBLISH semantics → no auto-demote;
409 TIMETABLE_PUBLISH_CONFLICTrequires the admin to explicitly demote the existing PUBLISHED first. - Snapshot serialization → inline JSON in the POST body to the solver. Solver service is stateless one-shot — all diagnostic iterations happen inside a single call.
- Solver deployment + authn → co-located with the BE; shared bearer secret in env vars.
- Epic →
10 - Timetable Engine. - ClickUp ticket →
US-38 — Generate Timetable. - §5 API surface → filled inline above.
- §13 FE ↔ BE ↔ Service contracts → filled inline below.
Late-stage constraint corrections (chat 2026-05-26, after spec draft landed):
- S8 inverted from
TEACHER_DAY_OFFtoTEACHER_PRESENCE(schools want teachers present more days, not fewer). - H2 plain-language clarified: intervals and lunch are transparent to "consecutive" — only another lesson breaking the sequence violates contiguity. Formal statement unchanged (Slots is PERIOD-only).
- H11 dropped as a hard constraint; replaced by soft S10
HOMEROOM_DESIGNATED_ROOM_PREFERENCE. Reason: mandatory specialized subjects (gym, music, lab) inherently use different rooms but the subject↔specialized-room link doesn't exist in the data layer yet. Revisit when that link lands — see §9 Deferrals.
11. Verification plan¶
- Unit specs:
timetables.service.spec.ts— covers: snapshot construction (correct PERIOD-only filtering, correct effective week template resolution per grade, correct option-block expansion), result ingestion (atomic create of Timetable + N ScheduledLessons), status transitions (PUBLISH demotion rule, single-published invariant), pre-check execution (each of the 5 pre-checks produces the right violation shape).timetables.queries.spec.ts— view projection functions per the six views.-
timetable-violation-renderer.spec.ts— template library: each documented pattern in the brainstorm renders the expected narrative for synthetic unsat cores. -
E2E specs:
timetables.e2e-spec.ts— golden path: generate a feasible timetable for a fixture school, assert lessons cover all grades' PERIOD slots, exactly one room per lesson, option-block children share(weekday, periodOrdinalPosition)and have distinct rooms. Edit a lesson (move it, change room) — assert no validation rejection. List/show endpoints across the six views.timetables-infeasibility.e2e-spec.ts— for each of the 5 pre-checks, build a fixture that trips it and assert the structured violation. For at least 2 solver-infeasibility cases (teacher overbook, option-block teacher collision), assert the minimized unsat core renders a template-library message.-
Follow [[feedback_e2e_isolation_patterns]] — stamp uniqueness-bound fields with
Date.now();prisma.deleteManyinafterAll. -
Manual verification: spin up the solver service locally, drive generation from the FE for a seeded fixture school, walk all six views to confirm rendering correctness.
Patterns: chapter 09 (testing), and feedback_e2e_isolation_patterns.md for E2E discipline.
12. Sign-off¶
- Approved by:
- Date:
- Chat reference: brainstorm walkthrough chat 2026-05-26 (problem framing → A: output/data model → B: failure-mode UX → C: decision variables → existing-module read → spec draft). FE ↔ BE ↔ Service contracts (§13) pending separate brainstorm pass before sign-off.
Until this section is filled, no implementation code is written.
13. FE ↔ BE ↔ Solver-service contracts¶
Authoritative wire-schema reference for the three boundaries. Sourced from the contracts brainstorm in chat 2026-05-26.
13.1 BE ↔ Solver-service¶
13.1.1 Transport¶
- Protocol: HTTP/1.1 over the co-located internal network. Sync request/response, no streaming.
- Endpoint:
POST /v1/generate. URL-versioned — an incompatible schema change ships as/v2/generatewith both versions live for a deploy window. - Authn:
Authorization: Bearer $TIMETABLE_SOLVER_SECRET. Shared secret in env vars on both sides (see §7). - Content-Type:
application/json; charset=utf-8. - Case convention: camelCase end-to-end. BE serializes camelCase by default; solver-side Pydantic uses field aliases to bridge to Python's snake_case internals.
- Body size limit: solver accepts up to 10 MB. Comfortably above the §1.5.9 sizing estimate.
- Compression: none in V1.
13.1.2 Request schema¶
POST /v1/generate
Authorization: Bearer $TIMETABLE_SOLVER_SECRET
Content-Type: application/json; charset=utf-8
{
"config": {
"timeLimitSeconds": 60,
"randomSeed": 42,
"highHoursThreshold": 4, // S2 threshold
"softWeights": {
"teacherDaySpan": <int>,
"doublesPreference": <int>,
"departmentRoomPreference": <int>,
"homeroomRoomStability": <int>,
"evenWeeklyDistribution": <int>,
"teacherPresence": <int>,
"soloIsolatedPeriods": <int>,
"homeroomDesignatedRoomPreference": <int>
}
},
"weekTemplates": [
{
"id": "wt-...",
"slots": [
{
"id": "wt-...:MON:1", // composite: ${weekTemplateId}:${weekday}:${periodOrdinalPosition}
"weekday": "MON", // enum: MON|TUE|WED|THU|FRI|SAT|SUN
"periodOrdinalPosition": 1,
"wallStart": "08:00", // "HH:mm"
"wallEnd": "08:50"
}
// every PERIOD slot — LUNCH/INTERVAL/NA filtered at the BE boundary
]
}
],
"slotOverlapPairs": [
{ "a": "wt-A:MON:3", "b": "wt-B:MON:3" }
// symmetric, included once with a < b lexicographic ordering
],
"subjectGroups": [
{
"id": "sg-...",
"weeklyHours": 5,
"studentCount": 22,
"teacherId": "t-..." | null,
"homeroomId": "h-...",
"weekTemplateId": "wt-...", // effective WT of g's grade
"optionBlockId": "ob-..." | null, // non-null = option-block child
"subjectDepartmentId": "d-...", // for soft S3
"compatibleRoomIds": ["r-1", "r-2"] // BE-precomputed (§1.5.4)
}
],
"optionBlocks": [
{
"id": "ob-...",
"weeklyHours": 3,
"weekTemplateId": "wt-...",
"childSubjectGroupIds": ["sg-1", "sg-2", "sg-3"],
"homeroomIds": ["h-1", "h-2"]
}
],
"rooms": [
{ "id": "r-...", "capacity": 30, "departmentId": "d-..." | null }
],
"teachers": [
{ "id": "t-...", "availableDays": ["MON", "TUE", "WED", "THU", "FRI"] }
// filtered to teachers with ≥1 assigned subject group
],
"homerooms": [
{ "id": "h-...", "weekTemplateId": "wt-...", "designatedRoomId": "r-..." | null }
]
}
Field semantics worth calling out:
- slots[].id is the deterministic composite string. Both sides can derive it.
- slotOverlapPairs is BE-precomputed; the solver never does interval arithmetic.
- subjectGroups[].compatibleRoomIds is BE-precomputed per §1.5.4; the solver doesn't recompute compatibility.
- config.softWeights are passed per-request so the BE can tune without redeploying the solver.
13.1.3 Response schema¶
// HTTP 200 OK — always (transport errors below)
{
"status": "FEASIBLE" | "INFEASIBLE" | "TIMEOUT",
// Present when status = FEASIBLE
"assignments": [
{
"subjectGroupId": "sg-...",
"weekday": "MON",
"periodOrdinalPosition": 1,
"roomId": "r-..."
}
],
// Present when status = INFEASIBLE
"violations": [
{
"category": "TEACHER_DAILY_CAP", // matches §1.5.5 label set
"entities": { "teacherId": "t-...", "weekday": "TUE" }
}
],
// Always present
"solveMetadata": {
"wallClockMs": 12345,
"objectiveValue": 4250, // FEASIBLE only; null otherwise
"minimizationIterations": 8, // INFEASIBLE only
"minimizationBudgetExhausted": false // INFEASIBLE only
}
}
Status semantics:
- FEASIBLE — solver returned a complete assignment within budget.
- INFEASIBLE — solver proved no solution exists. violations carries the minimized labeled unsat core.
- TIMEOUT — wall-clock budget exhausted without proving either way. No assignments, no violations.
Statelessness (per §1.5.8): all infeasibility analysis (initial solve → unsat-core extraction → minimization) happens within the single call. The solver holds no state between requests.
13.1.4 Transport-level errors¶
// HTTP 400 / 401 / 500
{
"error": "INVALID_INPUT" | "UNAUTHORIZED" | "INTERNAL_ERROR",
"message": "<human-readable>"
}
The BE translates any solver-side transport error to 502 TIMETABLE_GENERATION_SOLVER_UNREACHABLE for the FE.
13.2 FE ↔ BE: Generation (POST /timetables)¶
13.2.1 Request¶
POST /timetables
Authorization: Bearer <admin JWT>
Content-Type: application/json
{
"name": "Draft 2026-09 v1", // optional; default: "Draft <ISO timestamp>"
"academicYearId": "ay-..." // optional; default: tenant's active AY
}
13.2.2 Success (HTTP 201)¶
{
"id": "tt-...",
"name": "Draft 2026-09 v1",
"academicYearId": "ay-...",
"status": "DRAFT",
"generatedAt": "2026-05-26T10:23:45Z",
"createdAt": "2026-05-26T10:22:51Z",
"updatedAt": "2026-05-26T10:23:45Z",
"lessonCount": 250,
"solveWallClockMs": 12345
}
Lessons are not returned inline. FE fetches via §13.3.
13.2.3 Failure (HTTP 422)¶
{
"outcome": "PRECHECK_FAILED" | "SOLVER_INFEASIBLE" | "SOLVER_TIMEOUT",
"violations": [
{
"source": "PRECHECK" | "SOLVER",
"category": "TEACHER_CAPACITY", // matches §1.5.5 / §1.5.7 label set
"message": "Teacher Mario Rossi is assigned 25 hours but can teach at most 18 (across 4 available days at 4 periods/day).",
"entities": {
"teacher": { "id": "t-...", "displayName": "Mario Rossi" },
"assignedHours": 25,
"capacityHours": 18
}
}
],
"solveWallClockMs": 12345 // omitted on PRECHECK_FAILED
}
messageis pre-rendered by the BE template library — FE displays directly.entitiesis denormalized (IDs + display names) and freeform per category in V1. FE strictly needs onlymessage;entitiesenables richer UX (links, tooltips).
13.2.4 No idempotency in V1¶
Double-clicking POST creates two timetables. Admin can delete the duplicate. No idempotency key.
13.3 FE ↔ BE: Views (GET /timetables/:id/lessons)¶
GET /timetables/:id/lessons?view=teacher&targetId=t-...
Authorization: Bearer <JWT>
// 200 OK
{
"timetableId": "tt-...",
"view": "teacher", // discriminator
"targetId": "t-...",
"target": { "id": "t-...", "displayName": "Mario Rossi" },
"slotGrids": [
{
"weekTemplateId": "wt-...",
"weekTemplateName": "Mon-Fri standard",
"slots": [
{ "weekday": "MON", "periodOrdinalPosition": 1, "wallStart": "08:00", "wallEnd": "08:50" }
// every PERIOD slot of this template — empty cells render from the absence of a matching lesson
]
}
// one entry for single-template views (grade / homeroom / subject-group / student)
// potentially many for cross-template views (teacher / room)
],
"lessons": [
{
"id": "sl-...",
"subjectGroup": { "id": "sg-...", "displayName": "Math 3A", "subjectName": "Mathematics" },
"weekday": "MON",
"periodOrdinalPosition": 3,
"wallStart": "10:00",
"wallEnd": "10:50",
"weekTemplateId": "wt-...",
"room": { "id": "r-...", "displayName": "Room 101" },
"teacher": { "id": "t-...", "displayName": "Mario Rossi" } | null,
"homeroom": { "id": "h-...", "displayName": "3A" },
"optionBlock": { "id": "ob-...", "displayName": "Second foreign language" } | null
}
]
}
View filter semantics (BE-side):
| view | lessons includes |
|---|---|
grade |
lessons where homeroom.gradeId = targetId |
homeroom |
lessons where homeroom.id = targetId |
subject-group |
lessons where subjectGroup.id = targetId |
room |
lessons where room.id = targetId |
teacher |
lessons where subjectGroup.teacherId = targetId |
student |
lessons where subjectGroup.students contains targetId (mandatory + selected option-block memberships are already materialized in the SubjectGroup → Student relation) |
Edge cases:
- Target exists with no lessons → 200 + empty lessons. slotGrids populated when a deterministic target template exists (grade / homeroom / student / subject-group); may be empty for teacher / room views with no lessons.
- Target doesn't exist in tenant → 404 (typed code per the target's domain).
- Option-block lessons returned as individual entries each carrying the same optionBlock reference. FE groups by (weekday, periodOrdinalPosition, optionBlockId) to render an option-block cell.
13.4 FE ↔ BE: Lesson CRUD¶
All three operate without constraint validation per chat 2026-05-26 — admin is trusted.
// CREATE
POST /timetables/:id/lessons
{
"subjectGroupId": "sg-...",
"weekday": "MON",
"periodOrdinalPosition": 3,
"roomId": "r-..."
}
// 201 → ScheduledLessonResponseDto
// 409 SCHEDULED_LESSON_COORDINATE_CONFLICT
// UPDATE
PATCH /timetables/:id/lessons/:lessonId
{
"weekday": "TUE", // optional
"periodOrdinalPosition": 4, // optional
"roomId": "r-..." // optional
}
// 200 → ScheduledLessonResponseDto
// 404 SCHEDULED_LESSON_NOT_FOUND
// 409 SCHEDULED_LESSON_COORDINATE_CONFLICT
// DELETE
DELETE /timetables/:id/lessons/:lessonId
// 204
// 404 SCHEDULED_LESSON_NOT_FOUND
Validation discipline (mirrors §5):
- Tenant-scope FK checks on subjectGroupId / roomId — security floor, kept.
- DB-level coordinate uniqueness — kept (P2002 → 409).
- Everything else — dropped in V1.
13.5 FE ↔ BE: Lifecycle (list / get / rename / publish / delete)¶
// LIST — no pagination V1
GET /timetables?academicYearId=ay-...
// 200
{ "items": [<TimetableResponseDto>], "total": 5 }
// GET single
GET /timetables/:id
// 200 → TimetableResponseDto
// 404 TIMETABLE_NOT_FOUND
// RENAME (PATCH name only in V1)
PATCH /timetables/:id
{ "name": "Renamed draft" }
// 200 → TimetableResponseDto
// PUBLISH / UNPUBLISH
PUT /timetables/:id/status
{ "status": "PUBLISHED" | "DRAFT" }
// 200 → TimetableResponseDto
// 409 TIMETABLE_PUBLISH_CONFLICT — another timetable in this AY is already PUBLISHED; admin must demote it first
// DELETE
DELETE /timetables/:id
// 204 (cascades to lessons via FK)
13.6 Cross-cutting notes¶
- Idempotency: none in V1 on any mutation. FE is responsible for not double-firing.
- Versioning: only the solver service is URL-versioned (
/v1/generate); FE-facing API isn't versioned (matches the rest of SIS). - Tenant isolation: standard middleware enforces
tenantId. Cross-tenant FK references on lesson CRUD are blocked at the service layer. - RBAC: scope
'configuration'for reads,'write'for non-status mutations, actions'generate'/'publish'for the two custom transitions. Per §6. - Audit trail: out of scope (US-34 / Epic 15 handles audit infrastructure separately). No audit emits in V1.
- Rate limiting: none in V1.
- CORS: inherits existing SIS BE CORS config; no new origins.
- Solver service unreachable: BE returns
502 TIMETABLE_GENERATION_SOLVER_UNREACHABLE. No automatic retry in V1; admin sees a try-again-or-contact-support message.