Skip to content

Exam Invigilator Duty Allocation

Status: in review (kwiloai_webapp#1440).

Once seats are allocated for an exam session, Kwilo generates the invigilator duty roster from the seat plan: how many invigilators each hall needs, who covers it, and who is already booked elsewhere at that hour. The exam controller can swap or remove any assignment by hand, and regenerating never discards those manual changes. This closes the gap left open by exam seating, whose non-goals explicitly deferred "invigilator ratio allocation (1 per 40 students, 2 per 60+)".

Why this exists

  • Who asked: colleges, relayed by Bhanu in the same ERP thread that produced the seating and payroll asks. Reported requirement, not observed usage.
  • User pain: the seat plan alone does not finish the exam controller's job. They still hand-write duty chits the night before, which is where a hall ends up with 55 students and one invigilator, or a senior faculty member is written onto two halls for the same 10:00 slot and it surfaces when the bell rings.
  • Cost of not doing it: the controller keeps working from a spreadsheet, and the seating feature loses most of its value because its output still needs a manual second pass every cycle.
  • Validated or guess: reported ask with a specific rule attached (40 students per invigilator, 60 maximum per hall). The ratio is treated as a default rather than a law, which is why it is configurable per session.
  • Counterfactual: remove it a week after shipping and the controller complains by name, because the alternative is a night of manual chits.

How it works

Mental model

Duty is keyed on the sitting, not the exam:

sitting = (room, exam_date, start_time)

This matters because cross-branch seating puts several exams' students in one room at one time. A duty attached to an exam cannot express which invigilator covers which hall, so the sitting is the only unit that matches reality.

seat allocations (already persisted)
GROUP BY room, exam_date, start_time  ->  headcount per sitting
required = max(1, ceil(headcount / students_per_invigilator))
for each sitting, in (date, time, room) order:
    candidates = teaching staff, minus on-leave, minus anyone booked in this slot
    sort by (duties so far, employee code), take what is needed

Headcount needs no new input. It is a GROUP BY over data the seating feature already stores.

The ratio

students_per_invigilator lives on the exam session, defaulting to 40. A hall of 60 at that ratio needs 2. The "maximum 60 per hall" half of the rule needs no new storage at all: ExamRoom.capacity already is that number, and seat allocation already refuses to exceed it.

Clash detection

A unique index on (employee_id, exam_date, start_time) is the entire double-booking defence. Postgres refuses the second insert, so there is no window between checking and writing, and no application-level check that can drift out of sync with the constraint.

Because the index carries no session id, slot occupancy is read across the whole org unit rather than per session. Two semesters running mid-terms in the same slot compete for the same people, and the allocator has to see that.

Fairness

Assignment is least-loaded-first: sort eligible staff by duties already assigned in this session, then by employee code. Sittings are processed in a stable (date, time, room name, room id) order, so re-running with unchanged inputs produces an identical roster.

The duty-load panel deliberately shows every eligible person's count, including zeros. Published rosters make fairness visible whether or not the UI cooperates, and hiding the counter is what generates complaints.

Behaviour worth knowing

  • Regenerating deletes only auto-generated rows. Manual swaps always survive.
  • A sitting the pool cannot fill is reported as a shortfall, never an error that aborts the run. A partial roster the controller can fix beats no roster.
  • Sittings exist as soon as seats do, and every one is technically short until invigilators are assigned. That pre-work state is presented as work pending, not as failure.
  • A run that staffs nobody (everyone on leave, or every candidate booked by a parallel session) says so explicitly rather than reporting success over an empty roster.
  • Someone who goes on leave after allocation still appears by name on their duty. That is precisely the row a controller needs to act on.

Non-goals (v1)

  • Cross-session carry-over fairness. Someone who did four duties last term is not owed a lighter load this term. The schema supports it as a count over past sessions; the heuristic stays session-scoped.
  • Seniority tiers or hierarchy-weighted workload.
  • Excluding the paper's own subject faculty. The requested pool is any active teaching employee, and small departments cannot afford the exclusion.
  • Excluding same-branch faculty, mirroring the seating anti-cheating rule. Same reason.
  • Notifying staff of their duties by email or push.
  • Non-teaching staff as invigilators.
  • Mobile view of duty rosters.

Rejected alternatives

  • Compute the roster on read, persist nothing. Honours a strict no-new-tables constraint, but gives no manual override, so a sick invigilator cannot be swapped. The roster also silently reshuffles whenever HR adds or deactivates an employee, and there is no frozen printable record. Controllers override every session in practice, so this produces a report rather than a workflow.
  • A second invigilator column on the exam row. Cannot express which invigilator covers which hall once cross-branch seating puts several exams in one room, so the model would misrepresent what actually happens.
  • Reusing timetable substitutions. Those rows are keyed on section, subject, period and weekday: a recurring-class shape that does not fit a one-off dated sitting.
  • Plain round-robin with no load tracking. Fair only when every session has the same number of rooms and slots, which is never true across internal tests and university exams.
  • Range-exclusion constraint for overlapping windows. Deferred rather than rejected. It would block a 10:00 to 13:00 duty against a 12:00 to 15:00 one, which exact-slot uniqueness cannot catch, but it needs a Postgres extension and no college in the pipeline currently runs staggered windows. Partial overlaps are surfaced as advisory warnings instead.

Edge cases

  • No seats allocated yet: the roster reads as empty and points the controller at seat allocation first, rather than erroring.
  • HR module not populated: allocation fails loudly with "add staff in HR first". It never falls back to instructor-role platform users, because those are a different set from HR's teaching employees and the two disagree.
  • Leave spanning part of a session: the leave check uses a coarse window across the session's date range, so someone on leave for one day of a five-day session is held back from all of it. Deliberate: it errs toward not booking someone who may be away, and the controller can swap them back in.
  • Manual row that is not the lead: if the surviving manual rows carry no lead, the next allocation promotes one, so a staffed hall always has exactly one lead invigilator.

Prior art

OpenEduCat's exam module (Odoo) ships supervisor rostering with manual assignment and a same-slot conflict alert. It does not derive invigilator count from headcount and has no load balancing. The 40-per-invigilator norm is Indian university practice and is absent from Odoo entirely.

Scheduling literature on the invigilator assignment problem converges on bounded duty counts, minimising deviation from the mean, and carry-over counters for the fractional remainder. Heavier machinery (constraint solvers, genetic algorithms) is out of proportion for a college ERP. This design takes the least-loaded-first heuristic and leaves carry-over as an upgrade the schema already supports.

Where it lives

  • apps/backend/src/erp/exam_ops/models.py: ExamInvigilatorDuty, plus students_per_invigilator on ExamSession.
  • apps/backend/src/erp/exam_ops/invigilator_service.py: sitting loader, eligible pool, allocation pass, duty read/reassign/delete.
  • apps/backend/alembic_erp/versions/erp0017_exam_invigilator_duties.py: table, ratio column, and the org-isolation row-level-security policy every ERP table carries.
  • apps/web/src/erp/pages/ErpExamOpsSessionPage/InvigilatorsTab/: roster grouped by sitting, shortfall banners, duty-load panel, swap and remove dialogs.

API

POST   /erp/exam-ops/sessions/{id}/invigilators/allocate  {overwrite}
GET    /erp/exam-ops/sessions/{id}/invigilators
PATCH  /erp/exam-ops/invigilator-duties/{duty_id}         {employee_id}
DELETE /erp/exam-ops/invigilator-duties/{duty_id}
GET    /erp/exam-ops/invigilator-candidates?exam_date=

Known gaps

Tracked as follow-ups, all verified against source:

  • #1444: the ratio is write-once at session creation. There is no session field-edit UI, though the backend PATCH is ready and required counts recompute on every read. Raising the ratio also leaves surplus invigilators with no signal.
  • #1446: individual duties stay editable after a session completes, unlike regeneration which is blocked. Possibly intentional for post-hoc corrections, but undocumented.
  • #1447: the candidate list is unbounded and not slot-filtered, so already-booked staff appear and are rejected on save.

Changelog

  • 2026-07-27: written alongside the implementation PR. Retroactive only in the sense that the design doc came first and this feature doc followed the review cycle.