From 114c8af50dd566c692988867d132270782d4e78d Mon Sep 17 00:00:00 2001 From: ameer Date: Sat, 2 May 2026 02:52:14 +0800 Subject: [PATCH] Add v1.0 implementation spec for live in-lecture quiz portal --- SPEC.md | 533 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100644 SPEC.md diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..16cdb1f --- /dev/null +++ b/SPEC.md @@ -0,0 +1,533 @@ +# Live In-Lecture Quiz Portal — Implementation Spec + +**Project:** `quiz` (live-paced classroom engagement quiz, Kahoot-style) +**Build location:** `/home/ameer/RD/Projects/Apps/quiz/` +**Public domain (planned):** `quiz.ahkhan.me` +**Owner:** Prof. Ameer H. Khan, Taizhou University +**Spec version:** 1.0 (2026-05-01) +**Stack (locked):** Python 3.11+, FastAPI, `websockets` (via FastAPI's WebSocket support), `aiosqlite`, vanilla HTML+CSS+JS frontend (no framework, no build step). + +This is a complete, build-ready specification. Implement exactly what is here. Do not invent new features. Where the spec is silent, choose the simplest reasonable option and document it briefly in code comments or in `NOTES.md`. + +--- + +## 1. High-level flow + +A teacher starts a quiz session. Students scan a QR code or open a URL like `https://quiz.ahkhan.me/?sid=ABC123`. They identify themselves once with student ID + name (saved as a signed cookie). They wait in a lobby. The teacher opens questions one at a time from an admin dashboard. Students answer within a configurable time window (default 60s); earlier correct answers score more points (linear time decay). After each question, the teacher reveals the answer and a top-5 leaderboard. At the end, the teacher downloads a CSV of all results. + +**This is a participation/engagement tool, not a summative assessment.** Anti-cheating is intentionally minimal (a single signed cookie discourages but does not prevent proxy answering); the in-lecture paper attendance sheet remains the load-bearing presence check. + +--- + +## 2. Personas + +- **Instructor** (1 per session): authenticates with a shared admin password (env-configured). Creates sessions, opens/closes questions, sees all stats, downloads CSV. +- **Student** (N per session): identifies with student ID + name, persists via cookie, answers questions when revealed, sees own rank + top 5. + +--- + +## 3. URL routes + +### Student-facing +| Path | Method | Purpose | +|---|---|---| +| `/?sid=` | GET | Student entry. If `sid` missing/invalid → "Ask your instructor for the link" page. Otherwise: serve student SPA. | +| `/api/session/` | GET | Public session metadata: `{title, state, current_question_idx, time_limit_default}` (no quiz content). | +| `/api/session//join` | POST | Body: `{student_id, name}`. Sets signed cookie, creates `participants` row (or updates name if `student_id` already present). Returns `{ok, cookie_id}`. | +| `/api/session//me` | GET | Cookie-authenticated. Returns `{student_id, name, total_score, submissions: [...]}` for the current student. | +| `/api/session//stats` | GET | Public stats for end-of-question display: `{question_idx, response_time_avg_ms, response_time_distribution, average_score, top5: [{rank, name, score}], your_rank?}`. Cookie-aware for `your_rank`. | +| `/ws/student/` | WebSocket | Cookie-authenticated. Per-client connection for state updates and submissions. | + +### Instructor-facing +| Path | Method | Purpose | +|---|---|---| +| `/admin/login` | GET / POST | Login form. POST: body `{password}`. On success sets `admin` signed cookie. | +| `/admin/` | GET | Admin dashboard SPA (requires admin cookie). | +| `/admin/api/quizzes` | GET / POST | List quiz pools / create new (POST: body `{title, pool_json, time_limit_default}`). | +| `/admin/api/quizzes/upload` | POST | Multipart upload of a pool JSON file (alternative to direct POST). | +| `/admin/api/sessions` | GET / POST | List sessions / create new (POST: body `{quiz_id}` returns `{sid, qr_url, join_url}`). | +| `/admin/api/sessions//csv` | GET | Download final results CSV. | +| `/ws/instructor/` | WebSocket | Admin-cookie-authenticated. Sends control commands, receives all real-time events. | + +### Other +| Path | Method | Purpose | +|---|---|---| +| `/healthz` | GET | Returns `{ok: true, version, sessions_active, ws_clients}`. | +| `/static/*` | GET | Static frontend assets (`student.html`, `admin.html`, `quiz.js`, `admin.js`, `style.css`, etc.). | + +--- + +## 4. Data model (SQLite, via `aiosqlite`) + +Tables: + +```sql +CREATE TABLE quizzes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + pool_json TEXT NOT NULL, -- the question pool, see §6 + time_limit_default INTEGER NOT NULL DEFAULT 60, -- seconds + score_fn_name TEXT NOT NULL DEFAULT 'linear_decay', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE quiz_sessions ( + sid TEXT PRIMARY KEY, -- 6-char Crockford base32, see §10 + quiz_id INTEGER NOT NULL REFERENCES quizzes(id), + title TEXT NOT NULL, -- snapshot of quiz title at session-create + state TEXT NOT NULL DEFAULT 'lobby', -- 'lobby'|'question_open'|'question_closed'|'between_questions'|'finished' + current_question_idx INTEGER, -- NULL when state='lobby' or 'finished' + started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at TIMESTAMP +); + +CREATE TABLE participants ( + sid TEXT NOT NULL REFERENCES quiz_sessions(sid), + student_id TEXT NOT NULL, + name TEXT NOT NULL, + cookie_id TEXT NOT NULL, -- random uuid stored in cookie too + joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (sid, student_id) +); + +CREATE TABLE question_events ( + sid TEXT NOT NULL REFERENCES quiz_sessions(sid), + question_idx INTEGER NOT NULL, + opened_at TIMESTAMP NOT NULL, + closed_at TIMESTAMP, + time_limit INTEGER NOT NULL, -- seconds, snapshot at open + PRIMARY KEY (sid, question_idx) +); + +CREATE TABLE submissions ( + sid TEXT NOT NULL REFERENCES quiz_sessions(sid), + student_id TEXT NOT NULL, + question_idx INTEGER NOT NULL, + answer TEXT, -- option key 'A'|'B'|'C'|'D' or NULL if missed + submitted_at TIMESTAMP, -- NULL if missed + elapsed_ms INTEGER, -- NULL if missed; else server-side measured from opened_at + score INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'submitted', -- 'submitted'|'missed'|'late_join' + PRIMARY KEY (sid, student_id, question_idx) +); + +CREATE INDEX idx_submissions_sid_qidx ON submissions(sid, question_idx); +CREATE INDEX idx_participants_sid ON participants(sid); +``` + +Use WAL mode (`PRAGMA journal_mode=WAL`) for concurrent read-during-write. + +--- + +## 5. State machine + +A `quiz_sessions` row moves through these states: + +``` +lobby ──open_question──> question_open ──close_question──> question_closed + │ + ┌────────next (if more Qs)──────┘ + ▼ + between_questions ──open_question──> question_open + │ + ┌─end_session─────┘ + ▼ + finished +``` + +- `lobby`: students can join, no question visible. Can transition to `question_open` (open Q0). +- `question_open`: question visible, accepting submissions. Server auto-transitions to `question_closed` when `time_limit` expires; instructor can also force-close early via `close_question`. +- `question_closed`: correct answer + histogram + leaderboard sent to all clients. Instructor advances via `next` → `between_questions`. +- `between_questions`: brief intermission, dashboard shows leaderboard. Instructor opens next Q via `open_question`. +- `finished`: session over. Final leaderboard pushed. CSV downloadable from admin. + +**Auto-close behavior:** server schedules an asyncio task at `open_question` time that fires after `time_limit` seconds and triggers `close_question` if state is still `question_open` for that question. + +**Idempotency:** `open_question(idx)` while already in `question_open` for the same `idx` is a no-op. Opening a different `idx` while `question_open` first auto-closes the current one. + +--- + +## 6. Question pool JSON format + +A quiz pool is a JSON document like: + +```json +{ + "title": "Week 9 Recap — Computer Organization", + "score_fn": "linear_decay", + "time_limit_default": 60, + "questions": [ + { + "id": "q1", + "text": "Which signal triggers the writeback to the register file?", + "options": { + "A": "MARWrite", + "B": "RegWrite", + "C": "MemRead", + "D": "PCsrc" + }, + "correct": "B", + "time_limit": 60, + "explanation": "RegWrite gates the W-phase writeback into the RF." + }, + { + "id": "q2", + "text": "...", + "options": { "A": "...", "B": "...", "C": "...", "D": "..." }, + "correct": "C" + } + ] +} +``` + +**Field rules:** +- `score_fn` (optional, default `linear_decay`): name of the scoring function in `app/scoring.py` registry. +- `time_limit_default` (optional, default 60): per-quiz default. Per-question `time_limit` overrides. +- `questions[].time_limit` (optional): per-question override. +- `questions[].explanation` (optional): shown to students after the reveal. +- Options must always be exactly the 4 keys `A`, `B`, `C`, `D`. Correct must be one of those. + +**Validation:** validate at quiz-create time; reject pool JSON that violates these rules with a clear error. + +--- + +## 7. Scoring (modular) + +Implement `app/scoring.py` with a registry pattern: + +```python +SCORE_FNS: dict[str, Callable[[bool, int, int], int]] = {} + +def register(name): ... + +@register("linear_decay") +def linear_decay(correct: bool, elapsed_ms: int, time_limit_ms: int) -> int: + if not correct: + return 0 + elapsed_ms = max(0, min(elapsed_ms, time_limit_ms)) + return round(1000 * (1 - 0.5 * elapsed_ms / time_limit_ms)) + +@register("flat") +def flat(correct: bool, elapsed_ms: int, time_limit_ms: int) -> int: + return 1000 if correct else 0 + +@register("exponential_decay") +def exponential_decay(correct: bool, elapsed_ms: int, time_limit_ms: int) -> int: + if not correct: + return 0 + import math + decay = math.exp(-2 * elapsed_ms / time_limit_ms) + return round(1000 * (0.5 + 0.5 * decay)) +``` + +Scoring is invoked server-side at submission time using the server-measured `elapsed_ms = (now - question_events.opened_at)`. For a late joiner who joins mid-question, `elapsed_ms` is still measured from the question's `opened_at` (NOT join time) so they are scored fairly within the same time window — this matches the user's requirement that late joiners get 0 unless they answer within the original window. + +**Important:** The previous discussion considered "decay starts from join time" for late joiners; the user's final clarification is **late/missed = 0**, so we treat late joiners exactly the same as anyone else: if they submit before the window closes they're scored normally by elapsed-since-opened, otherwise 0. + +If they connect after the question already auto-closed, their `submissions` row is created with `status='missed', score=0` for that question. + +--- + +## 8. WebSocket protocol + +All messages are JSON objects with a `type` field. Use camelCase for field names in JSON only if it conflicts with Python conventions; prefer snake_case throughout for consistency. + +### Client → Server (student) +```json +{ "type": "submit", "question_idx": 3, "answer": "B" } +{ "type": "ping" } +``` + +### Client → Server (instructor) +```json +{ "type": "open_question", "question_idx": 0, "time_limit": 60 } +{ "type": "close_question" } +{ "type": "next" } +{ "type": "end_session" } +{ "type": "ping" } +``` + +### Server → Student +```json +{ "type": "state", "state": "lobby", "current_question_idx": null, "title": "..." } + +{ "type": "question_open", + "question_idx": 3, + "text": "...", + "options": {"A": "...", "B": "...", "C": "...", "D": "..."}, + "time_limit": 60, + "opened_at_server_ts": 1714589123456, + "remaining_ms": 58200 } + +{ "type": "submit_ack", + "question_idx": 3, + "score": 830, + "elapsed_ms": 4200 } + +{ "type": "question_closed", + "question_idx": 3, + "correct": "B", + "explanation": "...", + "your_answer": "B", + "your_score": 830, + "histogram": {"A": 5, "B": 18, "C": 3, "D": 1, "missed": 2}, + "top5": [{"rank":1,"name":"...","score":4520}, ...], + "your_rank": 7, + "your_total": 2940 } + +{ "type": "between_questions", + "next_idx": 4, + "top5": [...], + "your_rank": 7, + "your_total": 2940 } + +{ "type": "session_ended", + "final_top5": [...], + "your_rank": 7, + "your_total": 2940, + "questions_answered": 8, + "questions_correct": 6 } + +{ "type": "error", "code": "...", "message": "..." } +{ "type": "pong" } +``` + +### Server → Instructor +Everything the student gets, plus: +```json +{ "type": "lobby_update", + "participants": [{"student_id":"...","name":"...","joined_at":"..."}], + "count": 24 } + +{ "type": "live_histogram", // pushed periodically while question_open + "question_idx": 3, + "histogram": {"A":5,"B":12,"C":2,"D":0,"missed":0,"pending":5}, + "submitted_count": 19, + "total_count": 24 } + +{ "type": "full_leaderboard", // sent on between_questions and session_ended + "leaderboard": [{"rank":1,"student_id":"...","name":"...","score":...}, ...] } +``` + +### Reconnection +On WS connect, server immediately sends a `state` message with the current session state. If `state == question_open`, it follows with a `question_open` message including correct `remaining_ms` so a reconnecting client sees the right countdown. + +If a student reconnects and they have already submitted for the active question, the `state` follow-up includes a `submit_ack` echoing their stored answer + score. + +--- + +## 9. Frontend pages + +Three SPAs (each = single HTML + small JS module). No framework. No build step. Use ES modules served as-is. + +### `/static/student.html` (served at `/`) +- Reads `?sid=` from URL. +- If no `sid` or invalid: shows "Ask your instructor for the link" page with a friendly message. +- Calls `GET /api/session/` to fetch metadata. +- Checks for cookie. If absent or `sid` mismatch: shows ID+name form. On submit: `POST /api/session//join`, sets cookie via response. +- After cookie present: opens `/ws/student/`, renders state-dependent view: + - **Lobby:** "You're in. Waiting for instructor to start." + your name + spinner. + - **Question:** question text, 4 buttons (large, mobile-friendly), countdown bar driven by `remaining_ms` and local clock (resync on every server message). Disable buttons after submit. + - **Submitted (still open):** "✓ Submitted in 4.2s — +830 pts. Wait for the reveal." + - **Question closed:** show correct answer (highlighted green), your answer (highlighted red if wrong), explanation if present, top-5 leaderboard, your rank. + - **Between questions:** "Next question coming up. Your rank: 7/24." + - **Finished:** confetti or similar celebratory framing, final top-5, your rank, summary stats. +- Mobile-first responsive layout. Big tap targets. Dark mode okay. + +### `/static/admin.html` (served at `/admin/`) +- Login gate (POST `/admin/login`). +- Sidebar: list of quizzes, list of recent sessions. +- "Create session" button → modal: pick a quiz → returns `sid`, displays large QR code + join URL on screen. +- Active session view (one open at a time): + - Top: session title, state, connected count. + - Lobby tab: live roster. + - Per-question controls: prev | current Q (text + options) | next. Big `[Open]` / `[Close & Reveal]` / `[Next]` buttons. + - Live histogram chart (bar chart of A/B/C/D/missed counts) updates in real time during `question_open`. + - After close: show full leaderboard, correct-answer indicator, "Next" to advance. + - End-session button. + - "Download CSV" button always visible. +- Generate QR codes client-side (use a tiny vendored QR library, e.g. `qrcode-svg` ~3KB or a hand-rolled QR encoder). Or generate server-side as SVG via Python's `qrcode[svg]`. Pick whichever is simpler; document choice. + +### `/static/observer.html` (optional, served at `/observe/?sid=...`) +**Skip if time-pressed.** Minimal cookieless view useful to project on a classroom screen: shows current Q + live histogram + leaderboard. Read-only, no submit. Lower priority than the two above. + +--- + +## 10. Identifiers, secrets, cookies + +**Session ID (`sid`):** 6-char Crockford base32 (alphabet `0123456789ABCDEFGHJKMNPQRSTVWXYZ`, no I/L/O/U), generated cryptographically random. Display uppercase. ~10⁹ space, ample for collision avoidance at our scale; on collision, regenerate (max 5 retries). + +**Student cookie:** name `qz_student`. Value = signed JSON `{sid, student_id, name, cookie_id}`. Use `itsdangerous.URLSafeSerializer` with `SECRET_KEY` env var. Cookie attributes: `HttpOnly`, `SameSite=Lax`, `Secure` (when behind HTTPS), `Path=/`, `Max-Age=31536000` (1 year). + +**Admin cookie:** name `qz_admin`. Value = signed `{is_admin: true, ts: ...}`. Same security attributes. `Max-Age=86400` (1 day). + +**Admin password:** env var `QUIZ_ADMIN_PASSWORD`. Reject login if env var unset (don't allow unauthenticated admin). + +**Single-cookie design (per spec lock):** the cookie holds everything for one identity. Clearing the cookie loses all in-progress state on the client; server still has the participant row. If a cleared-cookie student rejoins with a new `student_id`, they get a fresh participant row — duplicate participation, but server has both records. We do NOT actively block this (warn-only at most). The friction of clearing the cookie + re-entering data is the soft anti-proxy deterrent. + +--- + +## 11. Configuration (env vars) + +| Var | Default | Purpose | +|---|---|---| +| `QUIZ_DB_PATH` | `./quiz.db` | SQLite file path | +| `QUIZ_SECRET_KEY` | (required) | Cookie signing key | +| `QUIZ_ADMIN_PASSWORD` | (required) | Admin login password | +| `QUIZ_HOST` | `127.0.0.1` | uvicorn bind host | +| `QUIZ_PORT` | `8001` | uvicorn bind port | +| `QUIZ_PUBLIC_URL` | `https://quiz.ahkhan.me` | Used to construct join URLs and QR codes | +| `QUIZ_LOG_LEVEL` | `INFO` | Logging level | + +Provide a `.env.example` file listing all of these with comments. Never commit real values. + +--- + +## 12. Project layout + +``` +quiz/ +├── README.md # how to run, dev workflow +├── SPEC.md # this file (already exists) +├── NOTES.md # implementation choices, edge cases, deviations +├── pyproject.toml # FastAPI, uvicorn, websockets, aiosqlite, itsdangerous, python-multipart, qrcode, pytest, pytest-asyncio, httpx, etc. +├── .env.example +├── .gitignore # quiz.db, .venv, __pycache__, .env +├── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI app factory, route registration +│ ├── config.py # env var loading +│ ├── db.py # aiosqlite connection, schema migrations +│ ├── auth.py # cookie signing, admin login +│ ├── models.py # pydantic models for API requests/responses +│ ├── pool.py # quiz pool JSON validation +│ ├── scoring.py # score function registry +│ ├── room.py # room manager (in-process state, WS broadcast, autoclose tasks) +│ ├── routes_student.py # student API + WS +│ ├── routes_admin.py # admin API + WS +│ └── csv_export.py # CSV download formatter +├── static/ +│ ├── student.html +│ ├── admin.html +│ ├── observer.html # optional +│ ├── quiz.js +│ ├── admin.js +│ ├── style.css +│ └── vendor/ # any tiny vendored libs (e.g., QR encoder) +├── tests/ +│ ├── conftest.py +│ ├── test_pool.py +│ ├── test_scoring.py +│ ├── test_auth.py +│ ├── test_api_student.py +│ ├── test_api_admin.py +│ ├── test_ws_student.py +│ ├── test_ws_admin.py +│ ├── test_state_machine.py +│ ├── test_late_join.py +│ ├── test_reconnect.py +│ ├── test_csv_export.py +│ └── test_load_simulation.py # see §14 +└── examples/ + └── week9_pool.json # sample pool covering W9 recap topics +``` + +--- + +## 13. Dev workflow + +```bash +cd /home/ameer/RD/Projects/Apps/quiz +python3.11 -m venv .venv +source .venv/bin/activate +pip install -e '.[dev]' +cp .env.example .env +# edit .env with QUIZ_SECRET_KEY (random) + QUIZ_ADMIN_PASSWORD +pytest # all tests must pass +uvicorn app.main:app --reload # local dev server on :8001 +``` + +Open `http://127.0.0.1:8001/admin/`, log in with admin password, create a session from the example pool, get the `sid`. Open `http://127.0.0.1:8001/?sid=` in another browser/incognito to test as student. + +--- + +## 14. Required tests + +All tests use `pytest` + `pytest-asyncio` + FastAPI's `TestClient` (sync) and `httpx.AsyncClient` (async, for WS). Use a fresh in-memory or temp-file SQLite per test. + +### Unit tests +- `test_pool.py`: pool JSON validation accepts well-formed pools, rejects: missing fields, wrong option keys, invalid `correct`, empty questions list. Hits all `pool.py` validation paths. +- `test_scoring.py`: each registered score_fn produces correct values for: correct+early, correct+late, correct+after-window, wrong, edge cases (elapsed_ms=0, elapsed_ms=time_limit_ms, elapsed_ms>time_limit_ms). +- `test_auth.py`: cookie signing roundtrip, tampered cookie rejected, admin login success/failure paths. + +### API tests +- `test_api_student.py`: `/api/session/` returns expected metadata; join endpoint creates participant + sets cookie; idempotent re-join with same student_id updates name; me/stats endpoints return expected shape. +- `test_api_admin.py`: login required for all `/admin/*`; quiz CRUD; session create returns sid+QR; CSV export shape correct. + +### WebSocket tests +- `test_ws_student.py`: connect with valid cookie succeeds; without cookie rejected (4001 close); receives initial state message; `submit` for open question returns `submit_ack` with correct score; `submit` for closed question rejected. +- `test_ws_admin.py`: requires admin cookie; control commands transition state correctly; broadcasts reach connected students. +- `test_state_machine.py`: full lifecycle test — create session, join 3 students, open Q0, all submit, close, advance, ..., end. Assert state transitions and DB rows. + +### Edge-case tests +- `test_late_join.py`: student joins after Q0 already opened — gets `state` + `question_open` with reduced `remaining_ms`. Submits in time → scored correctly. Joins after Q0 closed → no submission for Q0, status='missed' on later finalization. +- `test_reconnect.py`: student submits, disconnects, reconnects — server resends current state including their existing `submit_ack` for the active Q. + +### Load/simulation test (`test_load_simulation.py`) +- Spawn 50 simulated student WS clients in asyncio tasks (acceptance threshold; 100 if it runs in <30s). +- Run a full quiz with 5 questions; each simulated student answers within a randomized 1-50s window. +- Assert: no dropped messages, all submissions persisted with correct scores, final leaderboard CSV row count matches participant count, autoclose fires within 1s of `time_limit`. +- This is the single most important test — it validates real-world behavior. + +### Acceptance criteria +- All tests pass with `pytest -q`. +- `pytest --cov=app` ≥ 80% line coverage on `app/`. +- Manual smoke test (documented in `README.md`): create example session, join as student in incognito browser, submit answer, see reveal — works end-to-end without errors in browser console or server log. + +--- + +## 15. Operational notes (for later, not for codex implementation) + +These are documented for human deployment and should NOT be implemented by codex (no systemd, no Caddyfile, no DNS work needed in this scope). + +- Hosting target: mainland-China VPS (Alibaba Cloud Zhejiang) — to be provisioned by user separately. +- Reverse proxy: Caddy with auto-TLS via Let's Encrypt HTTP-01 on the host. +- Service supervision: systemd unit (template can be added later). +- Backup: nightly `sqlite3 .backup` to a second file; this is out of scope for v1. + +--- + +## 16. What NOT to build + +- No user registration / OAuth. Identity is just student_id + name + cookie. +- No live chat or Q&A. +- No file uploads from students. +- No multi-language UI (English only for v1; Chinese can be added later). +- No analytics dashboards beyond per-question stats. +- No mobile native app. +- No PDF report generation. +- No email notifications. +- No persistent leaderboard across sessions. + +--- + +## 17. Things explicitly OK to defer to `NOTES.md` + +If during implementation you discover a genuinely necessary deviation, document it in `NOTES.md` with: (a) what the spec said, (b) what you did instead, (c) why. Do not silently invent. Examples of acceptable deferrals: +- The exact QR-encoder library (server-side qrcode lib vs client-side JS). +- Whether `live_histogram` is pushed on every submit or throttled to once per 500ms (latter is fine, document it). +- Choice of pinning vs latest for dependencies (prefer compatible-release pins). + +--- + +## 18. Definition of done + +The project is "done" when: +1. All files in §12 exist (except observer.html, which is optional). +2. `pytest` passes with all tests green. +3. `pytest --cov=app` ≥ 80% line coverage. +4. `uvicorn app.main:app` starts without errors with a valid `.env`. +5. `examples/week9_pool.json` is a real, valid 10-question pool covering the W9 recap topics (CPU structure, datapath, control unit, FSM, hardwired vs microprogrammed). If you don't have domain knowledge to author content, generate plausible placeholder questions in the right format. +6. `README.md` documents: install, env setup, dev run, test run, manual smoke-test steps. +7. `NOTES.md` documents any deviations or non-obvious choices. +8. The load simulation test (`test_load_simulation.py`) passes with 50 simulated students.