Software requirements and design plan for a TA-to-course assignment tool for the McMaster ECE department. Source meeting notes: doc/TA_Assignment_Meeting_May_5.pdf
Build a tool that helps ECE department admins (Brennan and team) generate optimized assignments of graduate students (TAs) to undergraduate courses for the 2026-27 cycle, replacing the manual CAS-based process.
The operator is an ECE office admin. They are fluent in Excel, email, and the CAS web UI; they are not software engineers. They will not edit Python, will not read tracebacks, and will not memorize long flag lists. V1 has no web UI, but the CLI it ships with must still feel like a piece of office software: helpful prompts, plain-English errors, ready-to-distribute CSV templates, and a clear stage-by-stage workflow for collecting preference data from instructors and students. See §8 for the UX contract.
| Date | Event |
|---|---|
| 2026-06-09 | Platform ready for internal testing |
| 2026-06-15 | Launch (student-facing) |
| 2026-06-16 → 06-30 | Student application window |
| 2026-07-01 → 07-15 | Instructor preference window |
| Late July | Assignment processing (algorithm run) |
| First week of August | Assignments locked |
| Last week of August | Hours of Work forms due to CUPE |
- 1 week of development time for V1. V1 must be functional but minimal — it's the algorithm core plus structured I/O. UI and database layers come later.
- Domain model for Course, Student (TA), Instructor, Assignment.
- Constraint-based assignment engine (Timefold — see §4).
- File-based I/O: read instructor data, course data, student data, student preferences, and instructor preferences from CSV (and/or JSON/YAML).
- Export assignments + intermediate data in JSON, YAML, XML, CSV (human-readable).
- Admin-friendly CLI with subcommands
tcm init,tcm validate,tcm solve,tcm report,tcm demo— designed end-to-end for a non-developer office admin (see §8). - Input templates:
tcm init <cycle>scaffolds a cycle directory with pre-filled, ready-to-distribute CSV templates that the admin emails to instructors and students for preference collection. - Validation step (
tcm validate): a standalone read-and-check pass that emits a plain-English report — missing files, unknown MACIDs, capacity vs. demand, missing preferences — with file:line locations and recommended fixes. - Demo data generator (synthesizes realistic inputs from the 2025-26 cycle files for testing and admin onboarding).
- Sanity check pass before solving (workforce capacity, vetos vs. seats, etc.).
- Pre/post-solve reports (broken constraints, score breakdown, unassigned slots).
- Database: all I/O goes through a data-access abstraction (e.g.
DataLoader/DataExporter) so V2 can swap CSV for a real DB without touching solver code. - Authentication / user accounts: no login. CLI assumes the operator is trusted. V2 will add a web UI with role-based auth (admin, instructor, student).
- Email notifications: deferred to V2 (when the web layer exists).
- Edit history / audit log: deferred. V1 keeps input files in git and treats each run as a fresh snapshot — that's the audit trail for now.
- CV uploads, qualification text, co-op status: V1 supports these as opaque pass-through fields if present in the input, but the algorithm only uses preference rankings + previous-TA experience. Richer scoring comes later.
- Student veto: explicitly out per the meeting notes ("may create complaints").
- UI: optional stretch goal. If solver + I/O are stable by day 4, consider a thin Streamlit page for input review and output display. Otherwise, CLI only.
- Web UI: student portal (preference submission), instructor portal (ranking + veto), admin portal (run solver, review, lock).
- Postgres + SQLAlchemy persistence.
- Auth (probably OAuth via McMaster SSO).
- Notifications + edit history.
- Multi-section handling (Case 2 from the PDF): instructors choose "team" vs. "individual" preferences, individual mode pro-rates TA allocation across instructors.
Numbered for later traceability into TODO.md.
The system shall load the following from a configurable input directory:
| File | Content | Format (V1) |
|---|---|---|
courses.csv |
course code, section, level, term, # TAs required, instructor MACIDs | CSV |
instructors.csv |
MACID, first name, last name | CSV |
students.csv |
MACID, student number, name, # TA positions, program, level, supervisor MACID | CSV |
student_preferences.csv |
student MACID, course code, preference rank (1–5), prior_ta_for_course (bool) | CSV |
instructor_preferences.csv |
instructor MACID, course code, student MACID, rank, vetoed (bool) | CSV |
config.yaml |
constraint weights, solver time limit | YAML |
The 2025-26 CAS exports under doc/TA Assignment Files from CAS System - 2025-26 Cycle/ are the schema reference for courses/instructors/students.
- One course code (e.g.
ELECENG 3TQ3) may have multiple section rows (C01,C02) each with its own instructor. - V1 treats each section as a separate "course slot" for solver purposes but the export collapses sections into a single course for the student-facing view.
The solver shall produce an assignment that:
- Hard: satisfies course TA count, student TA-position count, and instructor vetos (see §5).
- Soft: maximizes alignment with instructor and student preferences (instructor weighted higher than student, per the PDF).
The system shall export the final assignment in all four of JSON / YAML / XML / CSV. The "master list" CSV format matches the existing manual Excel format from the 2025-26 cycle.
It shall also export:
- Per-course assignment summary (which students assigned to which course).
- Per-student assignment summary (which courses each student is teaching).
- Unassigned students / understaffed courses (the manual-review queue — expected 5–10% per the PDF).
- Score breakdown (which soft constraints scored how much).
Before solving, the system shall log warnings (with structured codes like [SOLVER.capacity]) if:
- Sum of required TA positions across courses > sum of TA capacity across students.
- All instructors vetoed a particular student.
- A course has more vetos than feasible TAs available.
Recommendation: yes, use Timefold.
- Problem structure is a classic assignment problem with weighted preferences and hard constraints — Timefold's bread and butter.
- The
HardMediumSoftScoremodel maps cleanly onto our constraint tiers (counts/vetos = Hard, capacity bounds = Medium, preferences = Soft). - We already have an internal pattern for this type of problem in example/ta-scheduler-timefold/ — domain model, constraint generator class hierarchy, solver orchestration, CLI scaffolding. We can lift this structure directly and adapt the entities from
Shift → CourseandTA → Student. - The coding-style document at doc/coding_style.xml is already written around Timefold patterns (constraint provider dict,
TimetableConstraintGenBase(ABC),sanity_check()on the planning solution). This is the path of least resistance. - Manual intervention (5–10%) is supported natively via
@PlanningPin— we can lock in admin overrides and re-solve.
- Java dependency: Timefold requires Java 17+. Fine for a CLI tool on the admin's laptop; will need bundling thought for V2 deployment.
- Overkill if requirements simplify: if the problem reduces to a pure 1:1 weighted bipartite match, the Hungarian algorithm (
scipy.optimize.linear_sum_assignment) is much simpler and faster. But once we add instructor vetos, multi-TA-per-course, varying TA-position counts per student, multi-section handling, and prior-experience tie-breakers, Timefold's flexibility pays off. - Learning ramp: I've used Timefold before for the lab scheduler, so the ramp is acceptable inside a 1-week window.
Lift the ta-scheduler-timefold example's structure into this repo. Rename entities (Shift → Course, TA → Student), redefine constraints around course assignment rather than shift scheduling, and reuse the CLI + benchmark scaffolding.
Mirrors the example's Shift / TA / ShiftAssignment / Timetable pattern.
@dataclass
class Instructor:
macid: str # primary key, e.g. "chenjun"
first_name: str
last_name: str
@dataclass
class Course:
id: str # synthetic, e.g. "ELECENG_3TQ3_C01"
course_code: str # "ELECENG 3TQ3"
section: str | None # "C01", "C02", or None
level: int # 2, 3, 4
term: int # 1 (fall) or 2 (winter)
num_tas_required: int
instructor_macids: list[str] # 1+ instructors
@dataclass
class Student:
macid: str
student_number: str
first_name: str
last_name: str
num_ta_positions: int # usually 2
program: str # "ECEPhD" / "ECEMASc"
level: int # 1-4 (year of program)
supervisor_macid: str
# Preferences
course_preferences: list[CoursePreference] # ranked 1..5
# Pass-through fields (not used by V1 solver, exported as-is)
qualifications_summary: str = ""
coop_status: str | None = None
cv_path: str | None = None
@dataclass
class CoursePreference:
course_code: str # student picks by course code, not section
rank: int # 1 = top choice
prior_ta_for_course: bool = False
@dataclass
class InstructorRanking:
instructor_macid: str
course_id: str
student_macid: str
rank: int | None # 1, 2, 3, ... or None if not ranked
vetoed: bool = False@planning_entity
@dataclass
class CourseAssignment:
id: Annotated[str, PlanningId]
course: Course
assigned_student: Annotated[Student | None,
PlanningVariable,
Field(default=None)]
pinned: Annotated[bool, PlanningPin] = False # manual override support@planning_solution
@dataclass
class AssignmentSolution:
id: Annotated[str, PlanningId]
courses: list[Course] # ProblemFactCollectionProperty + ValueRangeProvider (for student via @PlanningVariable)
students: list[Student] # ProblemFactCollectionProperty + ValueRangeProvider
instructors: list[Instructor] # ProblemFactCollectionProperty
instructor_rankings: list[InstructorRanking] # ProblemFactCollectionProperty (joined in constraints)
constraint_parameters: ConstraintParameters # ProblemFactProperty (weights)
assignments: list[CourseAssignment] # PlanningEntityCollectionProperty
score: HardMediumSoftScore
solver_status: SolverStatus | None = Noneinstructor_preference_reward_rank1: int = 100 # Soft
instructor_preference_reward_rank2: int = 60
instructor_preference_reward_rank3: int = 30
student_preference_reward_rank1: int = 40 # Soft, weighted lower than instructor
student_preference_reward_rank2: int = 24
student_preference_reward_rank3: int = 12
student_preference_reward_rank4: int = 6
student_preference_reward_rank5: int = 3
prior_ta_experience_bonus: int = 15 # Soft tie-breaker
supervisor_match_bonus: int = 5 # Soft, very smallFollowing the TimetableConstraintGenBase(ABC) + variant subclass pattern from the example.
course_exact_ta_count— Each course shall have exactlynum_tas_requiredstudents assigned.student_exact_position_count— Each student shall be assigned exactlynum_ta_positionscourses.no_duplicate_student_course— A student shall not be assigned to the same course twice.no_vetoed_assignment— A student vetoed by any instructor on a course shall not be assigned to that course.level_eligibility(optional, decide after consulting Brennan) — e.g. only PhD students teach 4xxx courses, etc. Open question — see §9.
student_course_term_spread— If a student has 2 TA positions, prefer (medium-penalize the inverse) one in each term (fall/winter), so they aren't slammed into a single semester.
reward_instructor_preference— Reward proportional to the instructor's ranking of the assigned student (rank 1 > rank 2 > rank 3 …). Weighted highest per the PDF.reward_student_preference— Reward proportional to the student's ranking of the assigned course's course_code. Weighted second.reward_prior_ta_experience— Small reward ifstudent.course_preferences[course].prior_ta_for_course == True. Used as tie-breaker per the PDF.reward_supervisor_match— Tiny reward if the course instructor is also the student's supervisor (anecdotally common — the 2025-26 data shows many students supervised by Emadi/Nahid-Mobarakeh/Chen, etc.). Sanity-check this against admin policy.
default: 1, 2, 3, 4, 7, 8, 9 — the safe baseline.with_term_spread: default + 6.with_supervisor_match: default + 10.full: 1–10.
(Pattern matches constraints_provider_dict in example/ta-scheduler-timefold/src/hello_world/constraints.py.)
data/2026-27/
courses.csv
instructors.csv
students.csv
student_preferences.csv
instructor_preferences.csv
config.yaml
(optional: pinned_assignments.csv — manual locks before solving)
results/2026-27/<run_timestamp>/
master_list.csv # primary deliverable, matches CAS Excel "Master List" format
assignments.json # canonical structured form
assignments.yaml # human-friendly
assignments.xml # if a downstream tool wants XML
per_course.csv # one row per course, listing assigned students
per_student.csv # one row per student, listing assigned courses
manual_review.csv # unassigned / understaffed / flagged cases
score_breakdown.json # which soft constraints contributed what
run_log.txt
The PDF doesn't pin a format, and Brennan called out that "downloading TA Assignment data into an Excel sheet" was the painful manual step last year. CSV is the operational primary; JSON/YAML/XML are for interoperability with whatever V2 / external tooling we wire in later. Implementing all four is cheap because they're all just serializations of the same AssignmentSolution dataclass tree — one to_dict() method on the solution + four short writer functions.
V1 has no web UI, but the CLI is still the surface a non-developer admin will interact with. This section defines the operator experience.
Each cycle (e.g. 2026-27) goes through four operator-facing stages. Each stage is one subcommand. Between stages the admin works in their existing tools (email, Excel).
| Stage | Subcommand | What the admin does |
|---|---|---|
| 1. Scaffold | tcm init <cycle> |
Creates data/<cycle>/ with pre-filled CSV templates and a per-cycle README. Admin fills in the roster columns they already have, then emails the preference templates to students/instructors. |
| 2. Ingest + validate | tcm validate <cycle> |
Once filled templates come back and are dropped into data/<cycle>/, this command parses everything, cross-checks MACIDs across files, runs sanity checks, and prints a human-readable issue list. Admin iterates until it exits clean. |
| 3. Solve | tcm solve <cycle> |
Re-runs validate, refuses on hard errors (override with --force), runs Timefold with streaming progress + best-score updates, writes a timestamped result directory, prints a plain-English summary. |
| 4. Report / re-export | tcm report <cycle> [<run_id>] |
Re-exports an existing solve in alternate formats or with focused views (--per-instructor, --unassigned). Lets the admin generate per-stakeholder attachments without re-solving. |
tcm demo (separate from a cycle) runs the full pipeline on synthetic data — useful for training a new admin and for solver smoke tests.
Each template is a plain CSV with (a) a comment header row describing each column, (b) one example row that the admin deletes before sending, and (c) any roster columns already populated from prior cycle / CAS exports where possible.
| Template | Distributed to | Returned via | Drop-in name |
|---|---|---|---|
courses_template.csv |
(admin fills from catalog) | — | courses.csv |
instructors_template.csv |
(admin fills from roster) | — | instructors.csv |
students_template.csv |
(admin fills from CAS export) | — | students.csv |
student_preferences_template.csv |
Each student via email | Filled CSV returned by email | student_preferences.csv |
instructor_preferences_template.csv |
Each instructor via email | Filled CSV returned by email | instructor_preferences.csv |
config.yaml |
(admin only — defaults are sensible) | — | config.yaml |
The per-cycle README explains: who fills each file, what every column means, which CSV is the deadline-driver, and the exact filename to drop the returned CSV under.
- No tracebacks reach the admin. A top-level exception handler catches everything, prints a one-paragraph admin-facing message, and points at the full log file in
logs/. - Friendly prompts when args are missing. If
<cycle>is omitted, the CLI lists detecteddata/*/directories and prompts the admin to pick one. - Plain-English validation errors. Instead of
KeyError: 'macid', print: "Couldn't find a 'macid' column in students.csv (file expected at row 1). The first row should be:macid,student_number,first_name,.... Open the file in Excel and add the missing column." Each error includes file:line and a recommended fix. - No silent overwrites.
tcm initrefuses to overwrite an existing cycle directory without--force.tcm solvewrites to a new timestamped subdirectory underresults/<cycle>/every run, so re-solves never clobber prior results. - Progress for long-running steps. Solve phase emits one line every N seconds with the current best score and elapsed time so the admin can see it's not hung.
- Terminal niceties. Use color and symbols (
✓✗⚠) by default; honor--no-colorandNO_COLORenv for piped output. - Distribution-ready outputs.
master_list.csvopens cleanly in Excel (correct quoting, no BOM surprises);per_instructor.csvis structured so the admin can split + email per-instructor without post-processing.
A single tcm script with subcommands (replacing the current tcm-solve / tcm-demo split):
[project.scripts]
tcm = "ta_course_match.main:cli"
main.cli() dispatches to cmd_init, cmd_validate, cmd_solve, cmd_report, cmd_demo. Keeps the admin's mental model small ("everything starts with tcm <verb>") and gives us one place to install the global exception handler, progress formatter, and colored output helper.
- Web forms. V2 replaces emailed CSV templates with a student/instructor portal that writes directly to the DB. The subcommand structure above carries forward unchanged — only the data backing of
init/validate/solveshifts from filesystem to database + HTTP. - Email integration. V1 admins still send templates and collect responses through their own email client; V2 wires McMaster SSO + mailer.
- Interactive wizard / TUI. Subcommand + flags is the V1 contract. If admins struggle in practice, V2 adds a guided
tcm wizardmode on top of the same building blocks.
- Level eligibility rules: Are there hard rules like "only 4xxx courses get PhD students" or is it informal? Affects whether to encode as a hard constraint or just a soft preference.
- Term distribution: Do students have a preferred term? Currently assumed not — the algorithm just distributes across terms.
- Multi-instructor courses (Case 1 from PDF): In the rankings file, do all instructors on a course share one ranking, or each submits their own (and we average / max)?
- Multi-section courses (Case 2 from PDF): For V1 we treat each section as independent. Confirm this is acceptable for the May-June test run.
- TBA instructor rows: 4 courses in the 2025-26 file have
TBAinstructor — how should V1 handle these? Skip until assigned? Treat as wildcard with no preference?
V1 is CLI + file I/O, but the structure is laid out so V2 can replace pieces without rewriting the solver:
[ CSV files ] [ Postgres ] ← V2
\ /
v v
[ DataLoader (abstract) ]
|
v
[ AssignmentSolution (domain) ] ← stable boundary
|
v
[ Solver (Timefold) ] ← stable
|
v
[ DataExporter (abstract) ]
/ | \
v v v
[ CSV/JSON ] [ HTTP API ] [ Email ] ← V2 adds
Concretely for V1:
- Define
DataLoaderandDataExporteras abstract base classes inutils.py(orio.py). - Concrete
CsvDataLoader/JsonDataLoaderetc. for V1. - V2 adds
SqlDataLoaderwithout changingsolver.pyordomain.py.
No login / auth in V1 — the CLI runs as the operator. V2's web layer is where auth lands; the domain model is auth-agnostic. See §12 for the V2 web architecture and auth provisioning plan.
Per doc/coding_style.xml. V1 code lives under src/ta_course_match/; V2 adds the parallel backend/ (FastAPI) and frontend/ (Next.js) trees described in §12.
ta-course-match/
src/ta_course_match/
__init__.py
domain.py
constraints.py
solver.py
utils.py
main.py
demo_data.py
io_loaders.py # CsvDataLoader, JsonDataLoader, ...
io_exporters.py # CsvDataExporter, JsonDataExporter, ...
backend/ # V2 — FastAPI service wrapping V1 modules (see §12.2)
main.py
deps.py
auth.py # AUTH SHIM — replaced by SSO in P5
schemas.py
db/
routes/
frontend/ # V2 — Next.js 14 App Router (see §12.2)
src/app/
src/components/
src/lib/
src/types/
tests/
conftest.py
test_domain.py
test_constraints.py
test_io.py
test_data/
data/ # gitignored
results/ # gitignored
configs/
solver_config.xml
default_weights.yaml
logs/ # gitignored
doc/ # already exists
example/ # already exists (timefold reference)
.python-version # "3.13"
pyproject.toml
uv.lock
CLAUDE.md
PLAN.md # this file
TODO.md
README.md
V2 puts a web frontend on top of the V1 solver. The design reference is doc/ui/design_handoff_ta_match_ui/README.md — that doc is the authoritative wireframe + token spec; this section describes how V2 is built incrementally on top of the existing V1 codebase.
- Incremental, not big-bang. Each phase ships a runnable slice (student portal alone, then instructor, then admin) backed by the existing CLI modules — no rewrites of
solver.py/domain.py. - The CLI is the source of truth for cycle data. V2's first end-to-end story is: admin runs
tcm init <cycle>andtcm validate <cycle>as today, then imports the resultingdata/<cycle>/directory into the web service. The DB is populated from existing CSV loaders. Nothing the admin already knows how to do gets thrown away. - Auth is provisioned, not built. A separate person finalizes SSO + session management; this codebase ships a clearly-marked auth shim so every page, route handler, and DB query already flows through
Depends(current_user)/useSession(). Swapping the shim for real auth is a 1-file change.
ta-course-match/
src/ta_course_match/ # V1 — unchanged. Solver, domain, loaders, CLI.
backend/ # V2 — FastAPI service wrapping the V1 modules.
main.py # app factory + lifespan (DB engine)
deps.py # current_user shim, get_session, get_loader
auth.py # AUTH SHIM (X-MacID header) — replace with SSO
schemas.py # Pydantic DTOs mirroring src/ta_course_match/domain.py
db/
models.py # SQLAlchemy ORM models
session.py # engine + sessionmaker; sqlite dev / postgres prod
migrations/ # alembic
routes/
cycles.py # /api/cycles* — import, list, dashboard, lock
preferences.py # /api/preferences/student/me, /instructor/:course
solve.py # /api/cycles/:cycle/solve (POST + SSE)
runs.py # /api/cycles/:cycle/runs*
frontend/ # V2 — Next.js 14 (App Router) + TS + Tailwind.
src/app/(student|instructor|admin)/... # route groups (handoff §Routes)
src/components/ui/ # shadcn-style primitives (hand-authored)
src/lib/api.ts # fetch wrapper + MacID header (auth shim)
src/types/api.ts # OpenAPI-generated; do not hand-edit
The backend starter in doc/ui/design_handoff_ta_match_ui/backend/ is the seed for backend/. The frontend in frontend/ has the student portal (S1 → S4) already standing.
This is the simplest path to a usable V2 — it leans on the V1 admin workflow instead of building a separate web ingestion path.
Admin laptop Server
───────────── ──────
tcm init 2026-27 ──fills CSVs──> data/2026-27/
tcm validate 2026-27 (loops until clean)
tar czf 2026-27.tar.gz data/2026-27/
──upload──> POST /api/cycles/import
│
▼
backend/routes/cycles.py
│ uses CsvDataLoader from V1
▼
DB tables populated:
cycles, courses, instructors,
students, instructor_rankings
│
▼
Students/instructors can now
log in (via auth shim) and see
their portal pre-populated.
What this means concretely:
- The first deployable V2 does not require V2-native admin screens for data entry. A4/A5/A8 (solve, results, lock) can come in Phase 3; A1/A2/A3 can stay CLI-driven indefinitely if needed.
- Students and instructors only ever interact with the web UI; the CLI never touches them.
- Re-importing a cycle is idempotent (upsert by primary keys). Admins can iterate on CSVs and re-upload without resetting drafts.
- Roster changes mid-cycle (a student drops out) are handled by editing the CSV and re-running validate + re-import. V2.1 can add a delta endpoint if this is painful.
A different team member owns the production auth stack. To keep them unblocked, this codebase ships explicit auth seams in fixed locations:
| Layer | Seam | Shim behavior (today) | Production behavior (separate PR) |
|---|---|---|---|
| Frontend route gate | src/app/(role)/layout.tsx |
reads X-MacID cookie set by a dev /login page; redirects if missing |
NextAuth.js (SAML) session; redirects to McMaster SSO if no session |
| Frontend fetch | src/lib/api.ts |
attaches X-MacID: <macid> header on every request |
attaches Authorization: Bearer <jwt> |
| Backend dependency | backend/deps.py::current_user |
reads X-MacID header, looks up DB row, returns CurrentUser (role inferred from which table the MacID lives in) |
verifies JWT signature against McMaster's JWKS, extracts MacID + role claims |
| DB schema | users.role column |
populated at cycle import (instructor MacIDs from instructors.csv, student MacIDs from students.csv, admin MacIDs from config.yaml) |
unchanged |
Non-negotiables for the auth-finalizer:
CurrentUserPydantic shape (inbackend/schemas.py) must not change — every route depends on it.- The shim never ships to production: a
TCM_AUTH_MODE=shim|ssoenv var gates which dependency function is registered, andshimmode refuses to start withENVIRONMENT=production. - All route handlers in
backend/routes/*already callcurrent_user = Depends(...)from day one. Adding SSO is changing the resolver, not adding new params across the codebase.
The handoff doc names every endpoint; this is which V1 module each one reaches into.
| Frontend page | Backend route | V1 module it calls |
|---|---|---|
| S1 welcome | GET /api/students/me |
reads from DB (populated by CsvDataLoader at import) |
| S2 rank, S3 review | GET /api/preferences/student/me · PUT (autosave) · POST submit |
DB-only (no V1 module) |
| I1 dashboard | GET /api/instructors/me/courses |
DB-only |
| I2 rank | GET /api/courses/:id/candidates · PUT /api/preferences/instructor/:courseId |
DB-only |
| A1 dashboard | GET /api/cycles/:cycle |
computes stage status from DB row counts |
| A2 init | POST /api/cycles/import |
io_loaders.CsvDataLoader.load() |
| A3 validate | GET /api/cycles/:cycle/validate |
domain.AssignmentSolution.sanity_check() + the validation pass from main.cmd_validate |
| A4 solve (SSE) | POST /api/cycles/:cycle/solve + GET ...?runId= |
solver.TaCourseMatchSolverWithSolverManager with a progress callback that pushes SSE frames |
| A5 results | GET /api/cycles/:cycle/runs/:runId |
reads persisted AssignmentSolution snapshot |
| A6 review queue | GET /api/cycles/:cycle/runs/:runId/manual_review · POST ...resolve |
derived from manual_review.csv logic in io_exporters |
| A7 course detail | same | same |
| A8 lock | POST /api/cycles/:cycle/lock |
sets DB flag + (V2.1) triggers mailer |
The pattern: everything DB-only is new code; everything that says "V1 module" is wrapping existing functions through Depends(get_loader) / Depends(get_solver). The V1 code does not change.
| Phase | Scope | Exit criterion |
|---|---|---|
| P0 — student portal demo | Frontend S1–S4 against mock-data.ts (done in frontend/) |
Walk the flow end-to-end in a browser; drafts persist locally. |
| P1 — backend foundation + import | FastAPI app, SQLite dev DB, alembic, POST /api/cycles/import wrapping CsvDataLoader, GET /api/students/me, GET /api/preferences/student/me, PUT, POST submit. Frontend swaps mocks for fetch. Auth shim wired. |
Admin can import a cycle via curl; a student can rank + submit and the data lives in the DB. |
| P2 — instructor portal | I1, I2.v1 (with @dnd-kit), I4. GET /api/instructors/me/courses, GET /api/courses/:id/candidates, PUT/POST per-course drafts. |
Instructor can rank candidates and submit. Co-taught (I3) deferred pending §9 answer. |
| P3 — admin solve + results | A4 SSE wrapping TaCourseMatchSolverWithSolverManager, A5 results screen, A1 dashboard. A2/A3 stay CLI-only — admins keep using tcm init / tcm validate. |
Admin can kick off a solve from the web, watch progress, view results. |
| P4 — admin review + lock | A6.v1 manual-review queue, A7 course detail, A8 lock flow. Pin/re-solve loop. | Admin can resolve edge cases and lock the cycle. |
| P5 — production auth handoff | Auth-finalizer replaces the shim with NextAuth (SAML) + JWT verification. No application code changes. | TCM_AUTH_MODE=sso boots in staging; shim mode disabled in prod. |
| V2.1+ | Notifications (mailer on lock), audit log UI, co-taught I3 (after §9 resolved), Postgres in prod, mobile graceful degradation. | — |
- Hosting / deployment topology. Likely a Docker compose stack (frontend, backend, postgres) but the exact target (McMaster on-prem? cloud VM?) is an ops decision.
CV upload + qualifications display.Resolved 2026-05-29 (dev/hand-off-v3): disk-backed PDF upload + viewer; the instructor "View CV" stub is now live and a dedicated swipe-mode viewer shows the CV beside rank controls.- Multi-instructor ranking semantics. Still open per §9 question #3. Blocks I3, not I2.
- The "fit" indicator on instructor candidates. Drop from V2 MVP unless a heuristic is agreed (handoff §"Open decisions" #5).
P0–P3 (student portal · backend foundation · instructor portal · admin solve+results) ship a complete data path: students submit, instructors rank, admin imports a cycle + runs the solver + downloads the master-list CSV. The pages render with the McMaster tokens but visually diverge from the V1 wireframes in stat-card composition, list/row polish, and four entire admin screens that were cut from MVP scope (A3 validate · A6 manual review · A7 per-course detail · A8 lock, plus the A2 init UI that we intentionally keep CLI-driven).
The detailed inventory (per-screen status / what's missing / effort) and the suggested 5-branch closure phasing (feat/web-shared-primitives → instructor-polish → admin-a3-a5 → admin-a4-a1 → admin-a6-a7-a8) live in TODO.md "V2 UI Polish — wireframe-fidelity gap closure". Cross-cutting infrastructure that several screens need (shared Stepper / Avatar / SeverityPill / HorizontalBar, a course.title column the schema is missing, and a few backend wraps for activity feed / per-constraint analysis / multi-file export) is called out in the same section.
Blocked items remain blocked: I3 (multi-instructor semantics — §9 #3), the I2 "fit" indicator (heuristic decision), "View CV" surfaces (CV scope), and A8 notifications (mailer ownership).
After the polish sprint shipped (PRs #3–#7), four functional gaps surfaced from real-world admin use. They're tracked as a separate iteration in TODO.md "Post-merge bug-fix round" with one short-lived feat/<area> branch per gap, smallest first:
- Save-on-manual-review — A6 commits pin / dismiss actions immediately on click. Switching to a "stage in local React state + commit on Save" pattern so the operator can try variations and discard.
- Activity log filter + download — the dashboard's Recent Activity card takes filter params (actor / action / since / until) and exposes a
/activity.csvcompanion endpoint. - Per-person submission views — two new admin pages (
/cycles/[cycle]/studentsand/cycles/[cycle]/instructors) drill down from the existing aggregate-count tiles so the admin can see who hasn't submitted. - Web setup wizard — full 5-step browser wizard for cycle creation (Name + windows → Roster CSVs → Preference CSVs (optional) → Weight overrides → Validate + finalize). Replaces the current
tcm init+curl POST /importworkflow for admins who don't want to use the CLI.
Non-negotiable CSV / IO compatibility rule for round 4 (and any future upload path): every CSV the wizard generates or accepts must round-trip cleanly through CsvDataLoader (src/ta_course_match/io_loaders.py). The wizard's template-download endpoint streams the canonical bodies from src/ta_course_match/templates.py _FILES rather than re-authoring the column lists in the frontend, so column shape cannot drift. The CLI's tcm init and the web wizard write equivalent data/<cycle>/ trees and tcm validate / tcm solve work against either interchangeably.
Written 2026-05-25, after PRs #9–#13 merged and the admin CRUD / preferences / all-clear features shipped on the wizard branch. This section captures an honest assessment of where the codebase stands against the May-5 meeting commitments, where the algorithm has known soft spots, and what the next two quarters of work look like. The execution-ready bullet list lives in TODO.md "Post-launch backlog"; this section frames the why.
- End-to-end data path works for the full cycle: scaffold (CLI or wizard) → student submits → instructor ranks → admin solves → manual review → lock. Every transition is exercised by the demo cycle in
data/example/. - Solver core is stable. The Timefold integration honors hard / medium / soft tiers correctly for the constraints that are wired;
ConstraintVerifier-style coverage would be a polish item, not a fundamental gap. - CSV-CLI parity is real. Both
tcm initand the web wizard write equivalentdata/<cycle>/trees; the wizard validates per-file with the same column rulesCsvDataLoaderuses at finalize. Round-trip tests live in tests/test_wizard_round_trip.py. - Auth seams are in place. Every route handler depends on
current_user; replacing the shim is a one-file change. Boot-guard already refuses to start withshimmode in production. - Destructive actions are now safe. Type-to-confirm on unlock + delete, the delete-while-running guard from #10, the lock-aware roster edits from the latest CRUD work, and the validate-before-rename on uploads collectively close the foot-gun surface.
- Docker stack runs both modes. The same image serves CLI subcommands and the FastAPI server; the frontend has its own Node container with hot-reload from host source.
- Trust-pack landed on
feat/ops-handoff-prep. Six items from §13.2 closed in one branch — config.yaml weights now live, solver concurrency is locked, thestudent_max_position_countslack and student-preference O(prefs) cost are gone, A5 metrics cache once terminal, and A4 progress streams via SSE. See HANDOFF.md for the DevOps-facing summary.
These are real risks, ordered by how visible they would be on Brennan's first serious use of the platform.
Resolved 2026-05-26 onconfig.yamlweights are a lie.feat/ops-handoff-prep—ConstraintParametersis now joined as aProblemFactPropertyin every soft-reward stream; YAML edits move the score.- Co-instructor preferences are siloed. The meeting PDF explicitly says co-instructors share a ranking view. Today each instructor has their own draft, filtered by
instructor_macid. This will be the first complaint from any course with two professors. - Multi-section Case 2 isn't modeled. No
section_modecolumn, no proration. A two-section course with two profs choosing "individual" mode silently double-allocates today. - No email notifications wired. The audit log captures the events; nothing sends mail. The PDF lists notifications as a hard requirement ("all instructors should be notified when preferences are submitted"); deferring it past launch is fine if it's named explicitly to the admin team.
- No instructor confirmation stage. The cycle goes
solved → lockedwith no intermediate confirmation, which means the PDF's "Students notified after instructor review" can't be honored. This blocks the August timeline. Student-preference lookup is slow.Resolved 2026-05-26 onfeat/ops-handoff-prep—Student.get_preference_rank/has_prior_ta_experiencenow hit a lazydictcache invalidated onlen(course_preferences)change.Resolved 2026-05-26 onstudent_max_position_counttolerates +1 over capacity as Hard.feat/ops-handoff-prep— slack stripped,count > num_ta_positionsis now a Hard violation.CV path exists; no upload pipeline.Resolved 2026-05-29 ondev/hand-off-v3— real multipart upload route (POST /api/students/me/cv, PDF-validated, 10 MiB cap), disk storage underTCM_DATA_ROOT/<cycle>/cvs/, and a viewer (GET /api/cycles/{cycle}/students/{macid}/cv, admin/in-cycle-instructor/self) wired into the student portal + instructor swipe-mode CV viewer.cv_pathstill accepts a legacy external URL (rendered as a link, not in the PDF iframe).A4 polling pulls full-table scans every 2s.Resolved 2026-05-26 onfeat/ops-handoff-prep— A5 metrics now serve from_TERMINAL_METRICS_CACHEonce a run is terminal, and the running path was migrated to SSE (GET /runs/{id}/events) so the FE stops re-pulling the full state every 2s.
Goal: every requirement from the May-5 PDF is either implemented or explicitly deferred in writing with stakeholder sign-off. Target landing before the June-15 launch if possible, otherwise as the first post-launch sprint.
| Branch | Scope | Driver |
|---|---|---|
feat/shared-co-instructor-rankings |
Drop instructor_macid from InstructorRanking PK (or add a merged view), record per-row last_edited_by / _at, update I2 to surface co-editor avatars. |
PDF Case 1 |
feat/multi-section-mode |
Add section_mode to Course + wizard step + proration in import + solver-side allocation split. |
PDF Case 2 |
feat/instructor-confirmation-stage |
New awaiting_confirmation stage between solved and locked; per-instructor approval row; instructor portal screen for confirming assignments before notify. |
PDF timeline |
feat/notifications |
Mailer integration (SMTP relay default, McMaster mail API if available). Triggers: instructor submission, admin "ready for confirmation", student "you've been assigned". Templates in backend/notifications/. |
PDF "send automatic emails" |
feat/cv-upload |
Shipped on dev/hand-off-v3 — POST /api/students/me/cv multipart, GET /api/cycles/{cycle}/students/{macid}/cv (admin/in-cycle-instructor/self), storage under TCM_DATA_ROOT/<cycle>/cvs/. Plus an instructor swipe-mode CV viewer. |
PDF "CV upload" |
feat/structured-coop-status |
coop_status enum (none / fall / winter / both), migration with best-effort string mapping, form widget. |
PDF "avoid vague questions" |
feat/config-weights-actually-applied |
Make ConstraintParameters a ProblemFactProperty consulted by reward expressions. |
Algorithm correctness |
feat/admin-course-crud |
Mirror the student/instructor CRUD for courses + the CourseInstructor link. Closes the "I forgot to add a section" papercut. | Operational reality |
Estimated total: ~12-14 working days. Not all need to land before launch — prioritize co-instructor + section-mode + confirmation-stage as the deadline-critical set; the rest can ship in early August.
Once stakeholder commitments are closed, this iteration tightens the edges that will be felt at department-wide scale and during admin debugging.
- Solver correctness/perf: model
StudentPreferenceas a Timefold problem fact (incremental scoring), drop the +1 hard slack, auto-scaletime_limit_sby problem size, audit the I2 UI for the multi-Preference-1 case. - Operational concurrency:
asyncio.Lockaround solver runs, per-run logger instance (no global mutation), stale-run reaper on uvicorn restart, JVM heap docs. - SSE migration: A4 progress switches from polling to SSE. Frees up the A5 metrics path to cache aggressively.
- CLI hygiene: implement
cmd_report, stop leaking tracebacks vialogger.exception, routetcm validatethrough the logger, honorNO_COLOR/--no-color, guard_resolve_cyclefor non-TTY. - Frontend polish: per-constraint score breakdown card on A5, preference-distribution histogram, A4 stop/extend buttons, compare-two-runs view, audit-log page, shared
Fieldform primitive. - Test coverage: route-level tests for the new admin CRUD (httpx + TestClient), ConstraintVerifier coverage for each constraint method, solver smoke against
data/example/in CI.
Estimated total: ~3 weeks.
These are deliberately not committed. They reflect where the platform could go if it lives past one cycle and earns continued investment.
- Cycle clone & longitudinal data. Starting cycle N+1 from cycle N's roster + supervisor relationships, with anonymized cycle-over-cycle KPIs.
- CUPE Hours-of-Work integration. Generate the CUPE form contents from the locked assignment so the admin's last-week-of-August task is one click.
- ML-assisted matching. Train on historical assignment success (instructor-reported TA performance + student satisfaction) to add a
predicted_fitsoft constraint. Cold-start data is N=1 cycle, so realistically a year out. - Multi-department support. Partition the schema on
departmentso ECE and CompSci can share the platform without forking. - Mobile-first portal. A11y + touch-target pass; not a redesign.
- Real-time co-editing. Beyond shared rankings — live cursors / CRDT for two profs editing simultaneously.
- iCal export. Once locked, each TA gets an
.icswith their assigned course lab times.
- Stakeholder gaps beat algorithm cleanups beat polish. A co-instructor sharing a draft is more important than a 30% solver speedup.
- Silent wrongness beats visible bugs. The
config.yaml-ignored case andstudent_max_position_countslack would each look "fine" right up until they suddenly didn't. Fix them before they cost trust. - Defer-in-writing, not by omission. Any meeting-PDF item we choose not to ship for V2.1 must be in the deferred list above with a named alternative (e.g. "no mailer this sprint — admin sends notifications manually") so the stakeholder isn't surprised.
- CLI parity is a feature. Every new web endpoint should either wrap an existing CLI helper or be backfilled with a CLI subcommand. The admin's escape hatch when the web stack misbehaves at 2 AM is the CLI.
Two read-only static security reviews of the FastAPI backend, core library, frontend, and deploy config (the standalone report files have been folded in here and removed). Execution items live in TODO.md "Security hardening"; this subsection frames the why.
Headline: the platform is well-architected for security — role checks are
consistent across ~67 authenticated endpoints, all admin mutations are
audit-logged, file/path operations are confined with relative_to guards + size
caps, there is no SQL-injection / eval / unsafe-deserialization / XXE surface,
and dependencies are current. For the documented trial (behind the auth
boot-guard and a TLS-terminating reverse proxy, internal users) the posture is
acceptable. There are three code-level bugs worth fixing now and a set of
production-hardening items that must close before any public deployment — the
largest of which (SSO) is already planned as P5 / §12.4.
Findings (absolute severity / deployment posture):
| ID | Finding | Severity | Posture | Home |
|---|---|---|---|---|
| F-1 | Auth shim trusts unverified X-MacID |
High (guarded) | Prod blocker, guarded for trial by the boot-guard | Already planned — P5 feat/web-auth-sso, §12.4 |
| F-2 | CSV / formula injection in 3 run-export writers (bypass the existing csv_safe()) |
Medium | Trial-impacting | TODO Security hardening |
| F-3 | No rate limiting on expensive / write endpoints | Medium | Prod hardening | TODO Security hardening |
| F-4 | Cross-cycle read via ?cycle= on GET /api/courses (non-admins) |
Low/Med | Trial-impacting | TODO Security hardening |
| F-5 | GET /api/system/schema unauthenticated |
Low | Trial-acceptable | TODO Security hardening |
| F-6 | Permissive CORS (methods/headers=*, credentials) |
Low | Prod hardening | TODO Security hardening |
| F-7 | Swagger /docs + /redoc exposed |
Low | Prod hardening | TODO Security hardening |
| F-8 | Default tcm:tcm Postgres creds in committed compose |
Low | Dev-only | TODO Security hardening |
Trial pre-flight: fix F-2 (small, self-contained), confirm
ENVIRONMENT ∈ {dev,test,ci} + explicit TCM_ADMIN_MACIDS, deploy behind a
reverse proxy that is the sole network path, pin TCM_CORS_ORIGINS.
Production blockers: F-1 (land SSO), F-3, F-4, F-6/F-7, F-8.
Second review — verified-resolved criticals. A second review (originally
against commit 33d913c, now also folded in) flagged two Critical issues
that were re-checked against current code and are already fixed: a repo-root
wipeout via a .. cycle name reaching shutil.rmtree (now blocked by
_RESERVED_CYCLE_NAMES + the relative_to(DATA_ROOT) guard in _cycle_dir,
wizard.py:99,211-221) and an unbounded, admin-spammable solve queue (now an
asyncio.Lock + 409 "already running" + _prune_live_runs,
runs.py:159,439-448). Its net-new open items — auth-probe MacID enumeration,
credential-in-localStorage, the dev sign-in gate shipping in prod, a
failed-solve error-message leak, a latent Content-Disposition header-injection
sink, missing HTTP security headers, and a few low items — are enumerated in the
TODO section.
Static performance review (no profiling/benchmarks) across the web backend, the Timefold solver + IO layer, and the React frontend. The standalone report has been folded into this section and the TODO.md "Scalability & ops", "Algorithm correctness", and "Frontend polish" sections (the report file has been removed).
Headline: the single highest-impact issue is that a fresh SQLAlchemy Engine
(and its connection pool) is created and disposed on every HTTP request —
pooling is defeated process-wide, taxing every endpoint. After that: a second
family of N+1 course↔instructor queries (distinct from the already-fixed
list_instructor_courses), the A5 metrics recompute that still bites on the
running-run poll path (the terminal-run cache is already shipped), and an
unmemoized drag-and-drop ranking UI.
Two accuracy notes carried over from the report (so the next reader doesn't over-prioritize):
- The JVM is not booted per request. Timefold/jpype boots once per process;
only
SolverFactory.create()(constraint compilation, ~1–2s) is paid per solve, which is minor against a 60–300s solve. - The terminal-metrics cache evicts FIFO, not LRU.
_evict_metrics_cachepops insertion order; the shipped note in §13.1 / TODO calls it "LRU." Harmless at the 100-entry cap, but the wording is wrong.
Top priorities (highest impact first): singleton DB engine · eager-load the
remaining N+1s · short-TTL cache for running-run A5 metrics · React.memo the
ranking cards + memoize the manual-review suggestion maps · reuse
solution_to_dict()/groupings across exporters · solver plateau-cutoff default +
de-dup the reward_student_preference rank lookup.
Three stakeholder-facing features built on top of the trial-ready base, each threading through the existing auth/DB/route conventions (no solver rewrite, no auth-seam change). DevOps-facing summary in HANDOFF.md §9.
-
Pin + capacity-safe re-solve. The A5 results page gained a per-row pin toggle and a "Re-solve with N pinned" CTA. The pin-add route now refuses (409) any pin that would exceed a course's
num_tas_requiredor a student'snum_ta_positions, so the pinned set always stays within capacity and the hard constraints (course_exact_ta_count/student_max_position_count) remain satisfiable. Pinned seats are pre-assigned +@PlanningPin-locked in_apply_pins, which is what makes the remaining demand the solver sees shrink correctly — pinned seats consume both course and student capacity, never double-counted. Unappliable pins (course/roster changed since pinning) are logged[PIN.skip], not silently dropped. Tests:tests/test_pin_resolve.py. -
CV upload + view + swipe-mode. Closes §13.2 #8 / the
feat/cv-uploadrow in §13.3. Students upload a PDF (magic-byte + content-type validated, 10 MiB cap); admins / in-cycle instructors / the student themselves view it inline. Storage is disk-backed underTCM_DATA_ROOT/<cycle>/cvs/<macid>.pdf(env-overridable; back it up — not in git, not in the DB) with traversal-safe path validation. The instructor ranking page gained a "Swipe + CV" mode that shows one candidate's CV beside rank/skip/veto controls, reusing the same bucket state as the drag board. Tests:tests/test_cv.py. -
Per-cycle announcements. A net-new feature (not in the original PDF): admins publish messages targeted at students, instructors, or everyone, per cycle; the matching audience sees a read-only banner on their portal (incl. the post-submission pages). New
announcementstable (alembic 0007), role-filtered reads, admin-only audit-logged writes, cross-cycle read guard. Tests:tests/test_announcements.py.
Verified live end-to-end with chrome-devtools-mcp against the example cycle
(announcement audience filtering, student CV upload, instructor swipe-mode PDF
viewer, pin toggle → "Re-solve with N pinned"). An adversarial multi-agent
review then surfaced 7 findings — 5 fixed on-branch (vetoed-pin guard so a pin
can't lock an infeasible solve; instructor-CV access requires teaching the
cycle; non-blank announcement title; CV object-URL revocation; blob-fetch error
detail) and 2 deferred-in-writing (see TODO.md V3 section). 220
backend tests pass; the Next.js production build is clean.