Timetable Generation (V2 rethink)¶
Re-derivation of the automatic-generation design from the current model: the landed manual-management module (Timetable/ScheduledLesson + 15-check window-aware diagnostics engine + publish gate) and the current schema (teaching windows,
CurriculumSubjectRoom, multi-teacherSubjectGroupTeacher,OptionBlock.maxSelections, standalone subject groups, per-department calendars). The 2026-05-26 spec's skeleton survives (CP-SAT in a stateless Python microservice, sync RPC, unsat-core explainability); its problem formalization (§1.5.2–§1.5.7) is rewritten here.
1. Problem distillation¶
- The admin triggers automatic generation of a school-wide weekly timetable for an AY: every schedulable
SubjectGroupplaced into PERIOD slots of its grade's effectiveWeekTemplate, each lesson in a room, honoring pre-assigned teachers — produced by an OR-Tools CP-SAT solver in a Python microservice living in this repo, called synchronously by the BE. - Governing principle: the diagnostics engine is the single authority on validity. The solver's hard-constraint set ≡ the engine's 12 ERROR checks; the 3 WARNING checks + quality preferences ≡ the soft objective. Generation is just another producer of
ScheduledLessonrows; the output is aDRAFTthat passes the existing publish gate by construction. - Defense-in-depth ingest: after a FEASIBLE solve the BE writes the lessons into a new
DRAFTtimetable, re-runscomputeDiagnostics, and asserts zero ERRORs. Any solver/model bug surfaces before an admin ever sees the draft. - On infeasibility, the admin receives structured, explainable violations (
{ category, severity, params }, i18n codes — no BE-rendered prose): BE pre-checks catch the common cases with exact arithmetic; a minimized unsat core labels the rest. - Teaching windows are first-class: the year is partitioned into elementary segments at window/calendar boundaries; disjoint-window subjects may legally tile one slot; coverage is per-segment.
Success criteria (observable behavior that proves this works):
- POST /timetables/generate on a feasible fixture school → 201 with a new DRAFT whose diagnostics report zero ERRORs (warnings allowed) — i.e. immediately publishable.
- A fixture with two half-year subjects (Art Sep–Jan, Music Feb–Jun) in one homeroom's curriculum → the solver tiles them into shared slots; coverage clean.
- A maxSelections=1 block → all children co-slotted; a maxSelections>1 block → children pairwise non-overlapping.
- A homeroom whose curriculum hours ≠ its template's PERIOD-slot count → generation fails fast with the HOMEROOM_HOUR_IDENTITY pre-check naming the homeroom, the segment, and both numbers — no solver call.
- A genuinely over-constrained instance (e.g. one teacher assigned two synced block siblings) → 422 with a minimized unsat core mapped to { category, params }.
- Solver unreachable → 502 TIMETABLE_GENERATION_SOLVER_UNREACHABLE; solver timeout → 422 with outcome: 'SOLVER_TIMEOUT'.
Non-goals (explicitly not this iteration — carried from the superseded spec unless noted):
- Partial-locks / regenerate-respecting-pins / anchor-based generation / multi-candidate generation.
- Per-school soft-weight tuning (weights are engineering defaults, passed per-request so the BE can tune without redeploying the solver).
- Async/queue-based generation — sync with a raised budget (§5); revisit on measured pressure.
- Per-student gap detection for maxSelections>1 blocks (inherent free periods accepted, matching the live engine).
- Teacher/student-facing read pipelines, notifications on publish, audit trail.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
2026-05-26-timetable-generation-design.md (superseded) |
The skeleton: CP-SAT, stateless one-shot POST /v1/generate, BE-precomputed snapshot (wall-clock, overlap pairs, compatible rooms), assumption-literal unsat-core minimization, fixed seed, shared-bearer-secret co-located deployment, per-request soft weights, soft-constraint vocabulary S1–S10. |
Its §1.5 formalization is stale: time-blind, single-teacher, pick-one-only blocks, fictional preAssignedRoomId/B.weeklyHours, H11 dropped on a premise (CurriculumSubjectRoom missing) that no longer holds, BE prose renderer, severity split contradicting the live gate. |
src/timetables/ (manual management, landed) |
The authority: ScheduledLesson as solver output shape (coordinate + room, nothing else), buildDiagnosticsSnapshot resolution primitives (effective template, wall-clock, PERIOD-rank, windows, rosters, room sets, maxSelections), computeDiagnostics as the post-ingest verifier, TimetableViolationDto {category, severity, params} as the violation wire shape, the publish gate the output must satisfy. |
The engine validates placed lessons; the solver must also know about unplaced demand (all schedulable groups, full homeroom coverage) — inputs the diagnostics snapshot never needed. |
src/timetable-templates/timetable-templates.queries.ts |
resolveEffectiveAssignment, loadAssignmentTreeForAcademicYear, computeSlotStartEndTimes, DAYS_OF_WEEK_ORDERED — the snapshot builder reuses the exact same resolution the diagnostics snapshot already uses. |
Templates are config; the generation snapshot is a per-call projection. |
src/mailer/ (MailerPort + log/resend/memory transports) |
The external-integration seam precedent: a port interface + env-selected transport, with an in-memory fake for tests. SolverPort + HTTP/fake transports follows it 1:1 — e2e tests stub the solver exactly like MAIL_TRANSPORT=memory. |
Mailer is fire-and-forget; the solver call is the request's critical path with a long budget and a rich response. |
src/command-center/ |
"Pure summarizer over a loaded snapshot" discipline — pre-checks are pure functions over the generation snapshot, unit-testable without DB. | Pre-checks gate a mutation rather than feed a dashboard. |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Snapshot loaders filter by tenantId; output rows carry it (existing Timetable/ScheduledLesson columns). The solver service is tenant-blind (receives an anonymous snapshot). |
Standard invariant; solver never touches the DB. |
| Academic-year scope | yes | GenerateTimetableDto.academicYearId (default: active AY). All inputs (groups, rooms, teachers, homerooms, template assignments) loaded for that AY. |
Generation is per-AY. |
| RBAC entity key | existing — TIMETABLES |
No new entity. | Generation is a creation path on the existing entity. |
| Scopes | existing — configuration |
No new scope. | Same admin-only surface as iter-1/2. |
| Actions | new — generate |
@RequireAction(EntityKey.TIMETABLES, 'generate') on the new route; + 'generate' in ACTION_NAMES (src/permissions/interfaces/decorators.interfaces.ts) and the seed catalogue. |
Non-CRUD state-producing transition; matches publish precedent. |
| Service base | custom | GenerationService in src/timetables/generation/ (module-foldering convention: timetables already >15 files; generation is a satellite). Route lives on the existing TimetablesController. |
Pipeline shape (snapshot → pre-checks → RPC → ingest → verify), not CRUD. |
queries.ts shape |
yes | generation.queries.ts: buildGenerationSnapshot(tenantId, academicYearId) (loaders + segment computation); pure runPreChecks(snapshot) in generation.prechecks.ts; toSolverRequest(snapshot) pure mapper. |
Same loader/pure split as the diagnostics engine. |
| Error codes | new — 4 | TIMETABLE_GENERATION_PRECHECK_FAILED (422), TIMETABLE_GENERATION_INFEASIBLE (422), TIMETABLE_GENERATION_TIMEOUT (422), TIMETABLE_GENERATION_SOLVER_UNREACHABLE (502). Defense-in-depth assert failure = 500 (generic internal error + structured log; it is a bug, not a user-facing state). |
Per-code granularity convention. |
| DTO conventions | standard | GenerateTimetableDto, GenerationResultDto, GenerationFailureDto under src/timetables/generation/dto/. Violations reuse TimetableViolationDto. |
Standard. |
| File-backed sub-resources | n/a — none. | ||
| Custom fields | n/a — runtime artifact. | ||
| Profile completeness | n/a — not a person entity. |
Python service layout (in this repo): solver/ at repo root — app/ (FastAPI + Pydantic schemas + CP-SAT model builder), tests/ (pytest), pyproject.toml, Dockerfile, README.md. Excluded from the Nest build/jest surface (they only target src/); own CI job. Deployment: separate co-located service (same Railway project), internal URL + shared secret.
4. Data model plan¶
Schema deltas¶
- None. Generation writes existing
Timetable+ScheduledLessonrows. Windows, room sets, rosters,maxSelectionsare all read-through, exactly like the diagnostics snapshot.
Migration shape¶
- n/a — no migration.
Indexes and uniqueness¶
- Unchanged. The
(timetableId, subjectGroupId, weekday, periodOrdinalPosition)unique and the single-PUBLISHED partial unique are unaffected (output is a freshDRAFT; ingest is a singlecreateManyinside a transaction).
4.5 Problem formalization (solver canonical reference — rewrites superseded §1.5)¶
4.5.1 Scope of the solve¶
All SubjectGroups of the target AY enter, partitioned:
- Schedulable: integer
weeklyHours ≥ 1cell at(curriculumSubjectId, gradeId), grade resolves an effective week template. - Skipped (standalone groups only):
weeklyHoursnull or fractional → excluded from the model, reported in the response asskippedSubjectGroups[](non-blocking; mirrors the engine, which skips null/fractional budgets and cannot see unplaced groups). - Blocking: a homeroom-bound group (or
maxSelections=1block child) with null/fractional hours is a pre-check failure (SUBJECT_HOURS_INVALID) — its slots could never be covered, so the instance is unsolvable, and silently skipping it would bake aHOMEROOM_GAPinto every output.
Group classification (current schema): homeroom-bound (homeroomId ≠ null, roster = its homeroom's HomeroomAssignment), block child (curriculumSubject.optionBlockId ≠ null, roster = SubjectGroupAssignment), standalone (neither, roster = SubjectGroupAssignment). Teachers per group = all SubjectGroupTeacher rows (co-teachers conflict equally, mirroring the engine).
4.5.2 Time model — elementary segments¶
- Collect all boundary dates: every non-all-year window's
teachingStartDate/teachingEndDate+ every department'scalendarStartDate/calendarEndDate. Sort → partition the year into elementary segmentst₁…tₖ(k = 1 when no windows exist — the formulation degenerates to the classic weekly problem). - Every window is a union of whole segments. BE precomputes: per-group
activeSegmentIds(all-year ⇒ all segments of its homeroom's/department's span, clipped to the homeroom span for homeroom-bound groups), per-homeroomsegmentIds(its department calendar span). windowsOverlap(g₁, g₂)⇔activeSegmentIdsintersect — set arithmetic on segment ids; the solver does no date math.- Variables stay weekly (below) — solver output remains isomorphic to
ScheduledLessonrows; a placement is "weekly, for the duration of the group's window", exactly the live semantics.
4.5.3 Decision variables¶
x[g, s] ∈ {0,1} g schedulable group, s ∈ SlotsOf(template(g.grade)) — g occupies slot s (during its window)
y[g, r, s] ∈ {0,1} r ∈ CompatibleRooms(g) — g occupies s in room r; Σ_r y[g,r,s] = x[g,s] (coupling)
b[B, h, s] ∈ {0,1} B maxSelections=1 block, h homeroom with children of B, s ∈ SlotsOf(template(h.grade))
— the block-unit occupies slot s for homeroom h
b is per (block, homeroom) — see 4.5.5 (engine-alignment fix: sync/clash scope is per grade/cohort, not global per block).
Structural absorptions (no constraint, no variable — can never appear in an unsat core; pre-checks own their impossible cases):
- Template alignment: x[g,s] only created for slots of g's grade's effective template.
- Teacher availability (TEACHER_AVAILABILITY): x[g,s] not created on weekdays ∉ any of g's teachers' daysOn (skip teachers with empty daysOn = unconfigured, mirroring the engine).
- Room capacity + subject room set (ROOM_CAPACITY, ROOM_NOT_IN_SUBJECT_SET): CompatibleRooms(g) = { r ∈ Rooms(AY) : r.maximumCapacity ≥ rosterCount(g) ∧ (subjectRooms(g) = ∅ ∨ r ∈ subjectRooms(g)) }. Room pool = all rooms of the AY, no RoomType filtering (lunch/interval resources are fixed on LUNCH/INTERVAL template slots, which never enter the solver).
4.5.4 Hard constraints (≡ the 12 ERROR checks)¶
| Label | Engine check(s) | Formal statement |
|---|---|---|
HOUR_BUDGET |
HOUR_BUDGET |
∀ g: Σ_s x[g,s] = weeklyHours(g); for maxSel=1 block children replaced by sync (below); for the block-unit: Σ_s b[B,h,s] = childHours(B) |
HOMEROOM_COVERAGE |
HOMEROOM_GAP + CLASS_CONFLICT |
∀ h, ∀ s ∈ SlotsOf(h), ∀ segment t ∈ h.segmentIds: Σ_{g mandatory of h, t ∈ g.active} x[g,s] + Σ_{B maxSel=1 of h, t ∈ B.active} b[B,h,s] + Σ_{C maxSel>1 child of h, t ∈ C.active} x[C,s] = 1 — exactly one occupancy per slot per segment; disjoint-window subjects tile, double-booking and gaps both excluded |
TEACHER_CONFLICT |
TEACHER_CONFLICT |
∀ teacher T, ∀ overlapping slot pair (s₁,s₂) [cross-template, wall-clock], ∀ g₁,g₂ ∋ T with windowsOverlap: x[g₁,s₁] + x[g₂,s₂] ≤ 1 (incl. g₁=g₂ on true slot pairs; co-teachers included) |
ROOM_CONFLICT |
ROOM_CONFLICT |
∀ r, ∀ overlapping (s₁,s₂), ∀ window-overlapping g₁,g₂: y[g₁,r,s₁] + y[g₂,r,s₂] ≤ 1 |
STUDENT_CONFLICT |
STUDENT_CONFLICT |
∀ (g₁,g₂) ∈ rosterClashPairs (BE-precomputed: rosters intersect ∧ windows overlap ∧ not already forced disjoint by coverage/sync), ∀ overlapping (s₁,s₂): x[g₁,s₁] + x[g₂,s₂] ≤ 1 |
OPTION_BLOCK_SYNC |
OPTION_BLOCK_SYNC |
∀ B maxSel=1, ∀ homeroom h, ∀ child C of (B,h), ∀ s: x[C,s] = b[B,h,s] |
OPTION_BLOCK_CLASH |
OPTION_BLOCK_CLASH |
∀ B maxSel>1, ∀ cohort (grade), ∀ sibling pair (C₁,C₂) with windowsOverlap, ∀ overlapping (s₁,s₂): x[C₁,s₁] + x[C₂,s₂] ≤ 1 |
| — (structural) | ROOM_CAPACITY, ROOM_NOT_IN_SUBJECT_SET, TEACHER_AVAILABILITY |
absorbed per 4.5.3 |
| — (pre-check) | NO_EFFECTIVE_TEMPLATE |
pre-check; a homeroom/grade without an effective template never reaches the solver |
Every materialized constraint instance carries an assumption literal labeled (category, entityRefs) for unsat-core extraction.
4.5.5 Engine-alignment fixes (BE-side, ship with this iteration)¶
Stress-testing alignment surfaced two over-broad groupings in the live engine — both key option-block checks by block id alone, lumping subject-group instances across grades (and homerooms): checkOptionBlockSync would force grade-5 and grade-6 instances of one block onto identical coordinates (meaningless across templates); checkOptionBlockClash would flag cross-grade "co-picks" no student can make (a student belongs to one grade). Fix the engine to key by cohort — sync per (block, homeroom), clash per (block, grade) — so solver and gate agree. Single-cohort blocks (the common case) are behaviorally unchanged; unit specs extend accordingly.
4.5.6 Soft objective¶
minimize Σ wᵢ · penaltyᵢ. Two tiers; all weights are per-request inputs with BE-side env defaults; calibration on the fixture school is part of bring-up acceptance.
Warning-mirror terms (heaviest — these were hard in the superseded spec, demoted to match the gate):
| Label | Mirrors | Penalty |
|---|---|---|
TEACHER_DAILY_CAP |
WARNING H6 | ∀ T, weekday d: max(0, (Σ lessons of T on d) − 4) (window-blind count, mirroring the engine) |
SAME_DAY_CONTIGUITY |
WARNING H2 | ∀ g, d with ≥2 placements: (lastRank − firstRank + 1) − count (PERIOD-rank span minus count; 0 when contiguous) |
SAME_ROOM_DOUBLE |
WARNING H13 | ∀ g, d, adjacent-rank pair: 1 if rooms differ |
Quality terms (carried from superseded §1.5.6, numbering preserved): S1 TEACHER_DAY_SPAN, S2 DOUBLES_PREFERENCE (threshold highHoursThreshold, default 4), S3 DEPARTMENT_ROOM_PREFERENCE (Room.departmentId vs subject's department), S4 HOMEROOM_ROOM_STABILITY (homerooms with baseRoomId = null), S6 EVEN_WEEKLY_DISTRIBUTION, S8 TEACHER_PRESENCE, S9 SOLO_ISOLATED_PERIODS, S10 HOMEROOM_BASE_ROOM_PREFERENCE (renamed from "designated" — field is Homeroom.baseRoomId; applies to mandatory groups of homerooms that have one, excluding groups with a non-empty subject room set that doesn't contain it — room sets are hard).
4.5.7 Pre-checks (BE-side, pure, run before the RPC; all violations {category, severity:'ERROR', params})¶
| # | Category | Predicate |
|---|---|---|
| 1 | HOMEROOM_HOUR_IDENTITY |
Flagship. ∀ homeroom h, ∀ segment t: Σ weeklyHours of h's occupancies active in t (mandatory + maxSel=1 blocks once + maxSel>1 children individually) = PERIOD-slot count of h's template. Exact arithmetic; catches most real infeasibility with a message no unsat core can match. |
| 2 | TEACHER_CAPACITY |
∀ T (daysOn ≠ ∅), ∀ segment t: Σ weeklyHours of T's active groups ≤ Σ_{d ∈ daysOn} maxPeriodSlots(d) across T's groups' templates (upper bound; daily cap is soft now, so the bound is slots-per-day, not 4) |
| 3 | ROOM_UNPLACEABLE |
CompatibleRooms(g) = ∅ — reports roster count, max capacity within the subject room set (or school-wide) |
| 4 | OPTION_BLOCK_TEACHER_DUPLICATE |
maxSel=1 blocks only: two children of one (block, homeroom) share a teacher (synced ⇒ simultaneous). Disjoint (maxSel>1) siblings may share teachers freely |
| 5 | OPTION_BLOCK_CHILD_MISMATCH |
maxSel=1: children of one (block, homeroom) must share weeklyHours and teaching window (identical slot sets are otherwise impossible / coverage-ambiguous) |
| 6 | NO_EFFECTIVE_TEMPLATE |
a homeroom (or a standalone group's grade) with schedulable demand resolves no effective week template |
| 7 | SUBJECT_HOURS_INVALID |
homeroom-bound / maxSel=1-child group with null or fractional weeklyHours (see 4.5.1) |
All pre-checks run; violations are bulk-reported (outcome: 'PRECHECK_FAILED'). Standalone-group skips (4.5.1) are reported alongside but never block.
4.5.8 Solver tech + infeasibility pipeline (unchanged from superseded §1.5.8 except budget + rendering)¶
OR-Tools CP-SAT; stateless one-shot RPC; assumption-based unsat-core extraction → iterative minimization → labeled (category, entityRefs) core returned. No relaxation pass in V1. Wall-clock budget: default 300s for the whole call (env-configurable both sides, passed per-request; BE HTTP client timeout = budget + 30s grace). Fixed random seed; best-found-within-budget acceptance. Violations cross the wire as labels + entity refs; the BE wraps them into TimetableViolationDto {category, severity, params} — no prose rendering anywhere (i18n keys, FE localizes; the superseded spec's template library is deleted from the design).
4.5.9 Sizing (revised)¶
Medium school (60 groups, ~30 slots/template, 3 templates, 25 rooms): x ~1.8k; y shrinks vs the superseded estimate because room sets are hard again (specialized subjects: |set| ≈ 1–3; unconstrained subjects: capacity-filtered) — ~10–20k; b ~hundreds (per block×homeroom). Segments multiply only the coverage constraint count (×k segments), not variables; student-clash pairs add pairwise constraints bounded by roster-intersecting pairs (dominated by option/standalone groups). Comfortably in CP-SAT range; the 300s budget is generous headroom, not an expected solve time.
5. API surface¶
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
POST |
/timetables/generate |
@RequireAction(TIMETABLES, 'generate') |
GenerateTimetableDto |
201 GenerationResultDto or 422 GenerationFailureDto or 502 |
(Route declared before the /:id param routes on the existing controller; handler delegates to GenerationService.)
GenerateTimetableDto—{ name?: string, academicYearId?: string }(defaults:"Generated <ISO timestamp>"FE-side / service fallback; tenant's active AY).GenerationResultDto—{ timetable: TimetableResponseDto, diagnostics: TimetableDiagnosticsDto, skippedSubjectGroups: { id, displayName, reason }[], solveMetadata: { wallClockMs, objectiveValue } }.diagnosticsis the post-ingest verification report (warnings-only by construction) — FE shows warnings immediately, no follow-up call.GenerationFailureDto—{ outcome: 'PRECHECK_FAILED' | 'INFEASIBLE' | 'TIMEOUT', violations: TimetableViolationDto[], skippedSubjectGroups: [...], solveMetadata?: { wallClockMs, minimizationIterations, minimizationBudgetExhausted } }.violationsempty onTIMEOUT.- Solver wire contract (
POST /v1/generate, bearerTIMETABLE_SOLVER_SECRET): request ={ config { timeLimitSeconds, randomSeed, highHoursThreshold, softWeights{...} }, segments[], weekTemplates[{id, slots[{id, weekday, periodOrdinalPosition, wallStart, wallEnd}]}], slotOverlapPairs[], subjectGroups[{id, kind: 'MANDATORY'|'BLOCK_CHILD'|'STANDALONE', weeklyHours, studentCount, teacherIds[], homeroomId?, gradeId, weekTemplateId, optionBlockId?, activeSegmentIds[], compatibleRoomIds[]}], optionBlocks[{id, maxSelections, cohorts[{homeroomId?|gradeId, childSubjectGroupIds[]}]}], rosterClashPairs[{a, b}], homerooms[{id, weekTemplateId, segmentIds[], baseRoomId?, mandatoryGroupIds[], blockIds[]}], rooms[{id, capacity, departmentId?}], teachers[{id, daysOn[]}] }; response ={ status: 'FEASIBLE'|'INFEASIBLE'|'TIMEOUT', assignments[{subjectGroupId, weekday, periodOrdinalPosition, roomId}], violations[{category, entityRefs}], solveMetadata }. camelCase end-to-end (Pydantic aliases); 10 MB body cap; transport errors (400/401/500) → BE502. - Env:
TIMETABLE_SOLVER_URL,TIMETABLE_SOLVER_SECRET,TIMETABLE_SOLVER_TIME_LIMIT_SECONDS(default 300).
Swagger considerations¶
POST /timetables/generatepolymorphic by HTTP status (201result envelope /422failure envelope);outcomeis the body-level discriminator. Error examples for all four new codes wired intoerror-examples.ts.- New pre-check
categoryvalues documented under theTimetableViolationDto.paramsper-category note (same convention as the diagnostics catalogue). - JSDoc on the route is FE-facing copy only (no solver internals) per [[feedback_swagger_jsdoc_is_public]].
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none |
PermissionAction (rbac-catalogue.ts) |
+ (timetables, 'generate') + ACTION_REQUIREMENTS row → ['timetables.configuration'] |
ScopeFieldMapping (rbac-catalogue.ts) |
none |
| Role grants (roles.ts) | none explicit — ADMIN auto-grants via ALL; no other role touches timetables |
*_SCOPES runtime constant |
none (scope unchanged) |
ACTION_NAMES union |
+ 'generate' (decorators.interfaces.ts) — same move publish required |
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
| Monolith: every operation in-process | Python microservice (in this repo, solver/), sync HTTP, shared bearer secret, co-located deployment |
CP-SAT has no production Node binding; solver complexity is irreducible | New deployment unit + Python toolchain in the repo; absorbed by co-location and the port/transport seam |
| Superseded spec: H2/H6/H13 hard | Soft (warning-mirror objective tier) | Hard ≡ ERROR tier — the gate is the authority; hard-coding what the gate only warns about over-constrains feasibility | Generated drafts may carry warnings (5-period teacher day, split double) — exactly like publishable manual drafts |
| Superseded spec: BE prose template library for violations | {category, severity, params} i18n end-to-end |
House style (iter-1 divergence ledger already declared prose the outlier) | FE must localize the new pre-check/core categories |
| Sync-for-short-ops convention | Sync with a 300s budget | User decision 2026-06-12: fill sync for now; async is a measured-pressure follow-up | Admin waits on a long request; FE needs a progress affordance |
| Engine = validation-only | Generation asserts zero post-ingest ERRORs (500 on mismatch) | Model/engine drift must be a loud bug, not a quietly broken draft | A solver-engine disagreement blocks generation entirely until fixed — intended |
8. Pushback log¶
| Source says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
Superseded spec §1.5: time-blind model, g.teacherId singular, preAssignedRoomId, B.weeklyHours, H10/pre-checks 3+5, H11-dropped-as-missing-link |
Current schema (windows, SubjectGroupTeacher[], no room pin, per-cell hours, CurriculumSubjectRoom exists) |
Full §4.5 re-derivation; stale constructs deleted | Resolved (chat 2026-06-12) |
Superseded spec H12: universal block sync via b[B,s] |
maxSelections>1 semantics (live engine iter-2) |
Sync at 1 / pairwise-disjoint at >1, per cohort | Resolved (chat 2026-06-12) |
Live engine: OPTION_BLOCK_SYNC/CLASH keyed by block id alone |
Cross-grade/cross-homeroom instances forced to sync (templates differ; cross-grade co-picks impossible) — generated outputs would trip false ERRORs at ingest | Engine fix: key sync by (block, homeroom), clash by (block, grade); solver implements the cohort-scoped semantics |
Resolved (chat 2026-06-12 — flagged during spec drafting) |
POST /timetables = generation trigger (superseded spec) |
Manual management landed that route as empty-draft creation | POST /timetables/generate |
Resolved (chat 2026-06-12) |
| Original 60s budget | Bigger model (segments, student pairs); user wants sync regardless | 300s default, env-tunable | Resolved (chat 2026-06-12) |
9. Deferrals¶
- Async/queue generation — sync at 300s for now — follow-up: revisit when measured solve times or platform proxy limits bite.
- Partial-locks / anchors / multi-candidate / regenerate-preserving-edits — carried from superseded spec — follow-up: post-V1 product pass.
- Per-school soft-weight tuning UI — weights are env/request-level engineering defaults — follow-up: revisit on placement-quality feedback.
- Relaxation diagnostic pass (which single category to relax to restore feasibility) — minimized core only in V1 — follow-up: add if production infeasibility messages prove vague.
- Per-student gap detection for maxSel>1 blocks — matches live-engine deferral — follow-up:
INFOtier territory. - Fractional
weeklyHourssemantics — skipped/blocked per 4.5.1; engine already skips them — follow-up: product definition if a tenant configures them. - Solver-side observability (metrics, solve statistics persistence) — logs only in V1 — follow-up: infra iteration.
10. Open questions¶
None — all resolved (chat 2026-06-12). Resolution trail:
1. Hard/soft alignment → hard ≡ 12 ERROR checks, WARNINGs + S-series ≡ soft objective (incl. daily cap soft).
2. Teaching windows → in scope, elementary-segment formulation, weekly variables.
3. Sync vs async → sync; budget 60s → 300s default, env-configurable.
4. Room pool → all rooms, no RoomType filtering (canteen/interval resources are fixed template-slot concerns, outside the solver).
5. Trigger → fresh DRAFT only via POST /timetables/generate.
6. Scope → all SGs of the target AY (default active), standalone included; null/fractional-hours standalone groups skipped + reported, homeroom-bound ones block.
7. Service location → this repo (solver/), FastAPI + Pydantic, own Dockerfile/CI, co-located deployment.
8. Engine alignment → cohort-scoped option-block keying fix ships with this iteration (4.5.5).
11. Verification plan¶
- Unit specs (BE):
generation.queries.spec.ts— snapshot construction: segment partition (boundaries, degenerate k=1), per-groupactiveSegmentIds/clipping,CompatibleRooms(capacity ∩ room set), roster-clash pair derivation (cross-homeroom disjointness via uniqueHomeroomAssignment.studentId, option/standalone intersections), cohort grouping for blocks.generation.prechecks.spec.ts— one block per category: hour-identity per segment (incl. maxSel>1 children counted individually, maxSel=1 blocks once), teacher capacity per segment, unplaceable room, sibling teacher dup (maxSel=1 only), child mismatch (hours + window), no-template, invalid hours (blocking vs skip split).generation.service.spec.ts— pipeline orchestration with a fakeSolverPort: pre-check short-circuit, FEASIBLE → transactional ingest + diagnostics assert (mismatch → 500 + no timetable persisted), INFEASIBLE/TIMEOUT mapping, unreachable → 502, AY default resolution.timetables.diagnostics.spec.ts— extended for the cohort-keying fix (multi-grade block no longer cross-flags; per-cohort sync still fires).- Python (pytest,
solver/tests/): - Model-builder units: variable domains (availability/template absorption), each hard-constraint family on minimal fixtures, segment-conditional coverage, sync/clash cohort scoping.
- End-to-end solves: feasible fixture school (assert assignment completeness + zero violations when re-checked by a Python-side mirror of the ERROR predicates on the output), window-tiling fixture, each engineered-infeasible fixture returns the expected category in the minimized core; determinism (fixed seed, identical output twice); timeout path.
- E2E specs (BE,
timetables-generation.e2e-spec.ts) — solver stubbed via the port (memory transport, mailer precedent): golden path 201 envelope (timetable + warnings-only diagnostics), pre-check 422 with structured violations, INFEASIBLE/TIMEOUT 422, RBAC (non-admin 403,generateaction enforced), generated draft passesPUT …/statuspublish. Follow [[feedback_e2e_isolation_patterns]]. - Manual verification: run the solver service locally (
docker compose/uvicorn), generate against the seeded fixture school, walk the six views, publish; introduce a hand-made infeasibility (e.g. shrink a template day) and confirm the pre-check message.
Patterns: chapter 09 (testing), feedback_e2e_isolation_patterns.md.
12. Sign-off¶
- Approved by: Fabio Barbieri
- Date: 2026-06-12
- Chat reference: rethink brainstorm 2026-06-12 — context re-gather (manual-management iter-1/2 + landed module + current schema) → staleness audit of the 2026-05-26 spec → six fork points resolved in chat (hard/soft alignment, windows-in-v1, sync@300s, all-rooms pool, fresh-draft trigger, all-SG scope) → repo-local solver service → cohort-keying engine fix + hours-split + success envelope flagged during drafting; approved by Fabio ("k for all") in chat.
Until this section is filled, no implementation code is written.