Skip to content

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-teacher SubjectGroupTeacher, 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 SubjectGroup placed into PERIOD slots of its grade's effective WeekTemplate, 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 ScheduledLesson rows; the output is a DRAFT that passes the existing publish gate by construction.
  • Defense-in-depth ingest: after a FEASIBLE solve the BE writes the lessons into a new DRAFT timetable, re-runs computeDiagnostics, 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 + ScheduledLesson rows. Windows, room sets, rosters, maxSelections are 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 fresh DRAFT; ingest is a single createMany inside 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 ≥ 1 cell at (curriculumSubjectId, gradeId), grade resolves an effective week template.
  • Skipped (standalone groups only): weeklyHours null or fractional → excluded from the model, reported in the response as skippedSubjectGroups[] (non-blocking; mirrors the engine, which skips null/fractional budgets and cannot see unplaced groups).
  • Blocking: a homeroom-bound group (or maxSelections=1 block 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 a HOMEROOM_GAP into 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's calendarStartDate/calendarEndDate. Sort → partition the year into elementary segments t₁…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-homeroom segmentIds (its department calendar span).
  • windowsOverlap(g₁, g₂)activeSegmentIds intersect — set arithmetic on segment ids; the solver does no date math.
  • Variables stay weekly (below) — solver output remains isomorphic to ScheduledLesson rows; 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 } }. diagnostics is 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 } }. violations empty on TIMEOUT.
  • Solver wire contract (POST /v1/generate, bearer TIMETABLE_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) → BE 502.
  • Env: TIMETABLE_SOLVER_URL, TIMETABLE_SOLVER_SECRET, TIMETABLE_SOLVER_TIME_LIMIT_SECONDS (default 300).

Swagger considerations

  • POST /timetables/generate polymorphic by HTTP status (201 result envelope / 422 failure envelope); outcome is the body-level discriminator. Error examples for all four new codes wired into error-examples.ts.
  • New pre-check category values documented under the TimetableViolationDto.params per-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: INFO tier territory.
  • Fractional weeklyHours semantics — 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-group activeSegmentIds/clipping, CompatibleRooms (capacity ∩ room set), roster-clash pair derivation (cross-homeroom disjointness via unique HomeroomAssignment.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 fake SolverPort: 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, generate action enforced), generated draft passes PUT …/status publish. 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.