Practice tests: retakeable Artifacts¶
Every Practice Hub session a learner starts is saved as a practice-test Artifact they can reopen, review question-by-question, and retake. The core contract: a retake is a new attempt on the same frozen questions of the same artifact — it never creates a new artifact, and the review always shows the learner's latest run with a percentage delta versus the previous one. Reuses the existing practice-session tables; no new tables. Related: Practice Player (the run UI it reuses), Practice Hub Engine (how a session's questions are chosen), and the Artifacts federation it plugs into.
Why this exists¶
- Who asked — self-initiated by the product owner, grounded in observed Practice Hub usage.
- User pain — a learner does 10 Optics questions, sees "30% accuracy" once, and it evaporates. There's no way to revisit which questions they got wrong, and no way to see whether they're improving. The attempt data was already stored, just invisible.
- Cost of not doing it — practice stays ephemeral; no visible improvement loop; the mistake data we already persist never helps the learner. Weaker motivation and retention.
- Validated or a guess — partial. The accuracy ring shows learners value the signal; "they'll revisit and retake" is a reasonable hypothesis, not yet validated. Watch re-open and retake rates after launch.
- How we'll know it worked — learners reopen saved tests and retake them; attempt-2 % beats attempt-1 % for a meaningful share. Sean Ellis test: if we removed "my practice history / retake" a week after shipping, would learners notice and ask for it back?
How it works¶
A practice_session row is the test/artifact — it freezes its question set at creation. A retake is a new run of that same set, tracked by attempt_number on each answer plus an explicit current_attempt pointer on the session.
Start (Practice Hub) Backend Review (Artifacts)
│── POST /practice-sessions ─▶│ create session
│ {subject, chapter_id} │ current_attempt = 1
│ │ test_number = N (per user+subject+chapter)
│◀──── questions (run 1) ──────│
├── answer each Q ───────────▶│ POST /{id}/attempts
│ │ writes attempt_number = current_attempt (1)
│ │ completed_at set when run 1 fully answered
│
│ …later, from the Artifacts card…
│ │◀── GET /practice-sessions/{id} ──│ open review
│ │ per-Q results scoped to current run
│ │ + attempt history (all runs) + title
│ │─── title, testNumber, attempts[], Q results ─▶│
│ │
├── Retake ──────────────────▶│ POST /{id}/retake
│ │ current_attempt += 1 (→ 2), completed_at = null
│◀──── same questions (blank) ─│ (run 2 has no answers yet → reads blank)
├── answer again ────────────▶│ writes attempt_number = 2
│ │◀── GET again ──│ review shows run-2 ticks + Δ%
Key behaviors:
- Retake never mints a new artifact. It bumps
current_attemptand re-runs the same frozenquestion_ids. The card, review, and history all stay on the one artifact. - Explicit run pointer, not
MAX(attempt_number).current_attemptis a column on the session. Submit writes at it; the summary GET scopes per-question answers to it. This is deliberate: inferring the run fromMAX(attempt_number)would make the first retake answer collide with the completed run's unique key, and would leak the prior run's answers into a fresh retake. - The summary GET serves the current run. A freshly-retaken run reads blank (so the player starts clean and a refresh resumes correctly); a completed run shows its ✓/✗. The card and attempt history instead use the last run with activity, so a card never shows a blank score just because a retake was opened and abandoned.
- Only sessions with ≥1 attempt are artifacts. A started-but-unanswered session never clutters the Artifacts list.
- Ownership everywhere. Submit, retake, summary, and delete all go through the owner-checked session load; the free-tier chapter gate is enforced on create, not re-charged on retake (a retake re-runs an already-owned test).
- Concurrency-safe counters. Retake and submit load the session
FOR UPDATE, so concurrent retakes/submits on the same session serialize on the row lock (no lostcurrent_attemptincrement, no answer landing in a stale run). Create takes a transaction-scoped advisory lock on(user, subject, chapter)before counting, so two simultaneous starts can't stamp the sametest_number.
Data model¶
No new tables. Three columns on practice_sessions, one on practice_attempts.
practice_sessions (= the test / artifact) practice_attempts (= one answer)
id id
user_id, subject, chapter_id, chapter_name session_id, user_id, question_id, chapter_id
question_ids (frozen set) selected_key, correct_key, is_correct
title NEW (nullable override) attempt_number NEW (which run: 1, 2, …)
test_number NEW (per user+subject+chapter) UNIQUE(session_id, question_id, attempt_number)
current_attempt NEW (the run in progress)
completed_at
current_attempt— 1 on create,+1on retake. The run the learner is currently on.attempt_number— stamped on each answer = the session'scurrent_attemptat submit time. The unique key now includes it, so the same question can be answered once per run.title/test_number— see Naming.
Per-run score is derived (GROUP BY attempt_number), not stored. Mastery/coverage in the Practice Hub Engine are unaffected structurally: coverage stays DISTINCT question_id; mastery counts every attempt (see Non-goals).
Naming¶
Because one subject+chapter can hold many tests, each carries a short handle plus disambiguating metadata:
- Default handle:
{chapter} — Test {test_number}(e.g. "Optics — Test 2"), wheretest_numberis a per-(user, subject, chapter)counter stamped at create. titleis a nullable override (for a future rename UI, or an AI-generated test's own name).- The distinguishing detail (subject, source, date) lives in the card's metadata chips, not crammed into the handle — so the handle stays short and multi-topic later just reads "Optics +2".
Artifacts surface¶
Practice tests federate into the existing Artifacts read view under the practice_test type, tagged with a source: "practice_session" discriminator (the AI-Tutor chat mock tests are the same type, source: "mock_test"). The card shows title · subject · latest correct/total + %; clicking a practice_session card opens the review screen at /practice/test/:id. Delete is owner-scoped and cascades to attempts.
Non-goals and rejected alternatives¶
- A new
practice_teststable (rejected). An earlier design added a Test→Run→Answer three-table hierarchy. Rejected as over-built:practice_sessionsalready freezes a question set, so one column (attempt_number) plus an explicitcurrent_attemptexpresses retakes with far less migration surface. - Storing hub tests in
mock_tests(rejected).mock_testsembeds AI-generated questions as JSONB and is single-attempt and conversation-bound — a poor fit for corpus-referenced, retakeable PYQ tests. It stays as-is for chat mock tests. - Inferring the run from
MAX(attempt_number)(rejected). See "explicit run pointer" above — it collides and leaks answers. - Multi-topic and AI-generated tests (out of scope). The schema is left open (
chapter_idsas a conceptual array, asourceaxis), but the UI ships single-chapter PYQ only. - Mastery double-count (accepted, not a bug). Retaking a question counts every attempt toward chapter mastery — deliberate: more practice is more signal, and getting a question right on retake correctly lifts mastery. Coverage stays distinct, so retakes never inflate it.
Known gaps (deferred)¶
- Mid-abandoned-retake review. If a learner opens a retake and leaves before finishing, the review shows questions neutrally as "not answered" (the current run is empty) while the header shows the last completed run's score. A fuller fix — the review showing the last-answered run per question via a
?view=reviewparameter — is deferred. - Card "attempts" pill. The card doesn't yet show an attempt count; needs an
attempt_countfield on the artifact item. - Rename UI.
titleoverride exists in the schema; no UI to set it yet (auto-names only).
Changelog¶
- 2026-07-10 — Initial version. Retakeable practice tests saved as Artifacts (review screen, retake, attempt history) shipped in
MySetu-AI/kwiloai_webapp#1313. Retroactive-but-concurrent: authored alongside the implementation PR, not after.