Medical — Exam & Sitting Classification — PRD
Context
Today the platform assumes one course = one exam, run once a year. A course is
a CourseEnum value (not a table); questions (McqDBModel) carry a single
course_id, are organised within a course by the subject taxonomy tree
(L1 → L2 → L3), and record a single year (mandatory for PYQs, feeding
YearsDBModel and year-based features).
Medical breaks that assumption. A single Medical course covers two different exams, and each exam runs multiple times a year. There is no way to record which exam a question belongs to, or which sitting it came from — so all Medical questions sit in one undivided pile.
This PRD adds a way to organise Medical questions by exam and by sitting, inside
one Medical course. It does not change the subject taxonomy, the existing
year feature for other courses, MCQ authoring/rendering, or how non-Medical
courses behave. The new structure is generic (a reusable classification axis)
but Medical is its only consumer at launch.
Scope
MVP — organise a single Medical course’s questions along a new exam/sitting axis:
- Existing Medical course (
DMED) — the single course already exists asCourseEnum.DMED(4, “Design Med”); no new course is added. The two exams live inside it, not as separate courses. - An exam tree, parallel to taxonomy — a new hierarchical entity (its own
collection,
exam) the content team manages the same way they manage taxonomy (create nodes, name, sort, publish). It is course-agnostic — Medical (DMED) is simply the first course to use it; any course can build an exam tree. Each level is a separate node document, exactly like taxonomy. - Typed nodes, flexible shape — each node carries a descriptive node type (EXAM / YEAR / PHASE), but any type may sit at any level and trees vary in depth (up to 3): not every exam is subdivided by year or conducted more than once, so a tree may stop at L1 (“CET”) or L2 (“CET → 2026”) or go to L3 (“NEET PG → 2026 → January Session”). A sitting is a PHASE-type node — the specific instance an exam ran.
- Questions filed onto the tree — each Medical question references one path on the exam axis, ending at a leaf of its exam’s tree (a question cannot stop at a node that has children — see Classification Rules), so the bank can be filtered by exam, by year, and by sitting (in addition to subject taxonomy).
- Existing
yearfield untouched — the MCQyearstays exactly as today (independent, hand-entered, mandatory for PYQ). The exam tree’sYEARlevel is a separate axis; this PRD deliberately does not couple or sync them. - Classification is mandatory for Medical (end state), rolled out in phases so release is not blocked on backfilling the legacy pile.
Data Model (requirements-level)
Course mapping (decided): “Medical” is one, already-existing course —
CourseEnum.DMED(4, “Design Med”). No new course is added. Exam and sitting are handled within it by the structure tree, not by adding/relabelling courses. The other medicalCourseEnumvalues (NEET,AIIMS,JIPMER,NEET_PG) are separate courses, untouched and out of scope.
A new exam tree, not the taxonomy table (decided): the exam/sitting axis is a new entity in its own collection —
exam(ExamDBModel) — mirroring the taxonomy node shape. The existingTaxonomyDBModelis left untouched (zero regression on taxonomy queries/counts). Genericity is carried bycourse_id(any course can grow an exam tree), not by adimensiondiscriminator — there is no dimension field; if a genuinely different classification axis ever appears, it gets its own collection, the same wayexammirrorstaxonomy.
Typed nodes, flexible shape (decided — supersedes the earlier strict level↔position binding): every node carries a descriptive
node_type(ExamNodeTypeEnum:EXAM = 1,YEAR = 2,PHASE = 3— an int enum, keystone style) that says what the node is, not where it may sit — any type may appear at any level. This is deliberate: not all exams are subdivided by year or conducted more than once, so trees legitimately vary in shape and depth. The only structural rule is max depth 3 (keepingexam_nodeswithin the same 3-element shape astaxonomy_ids). There is no type↔position or parent-type validation.
Path link on the question (decided): a question stores its exam membership as
exam_nodes— the root→end path of 1–3 node ids (e.g.[EXAM],[EXAM, YEAR], or[EXAM, YEAR, PHASE], ending wherever its tree ends) — plus a denormalisedroot_exam_id, mirroringtaxonomy_ids/root_taxonomy_id1:1 (flat$infiltering at any level, same index shape). Namedexam_nodes(notexam_ids) because the list holds node ids across levels, not ids of exams. (Supersedes the earlier dimension-keyedstructure_pathsidea, which died with the dimension field.)
yearis untouched (decided): the existingMcqDBModel.yearfield is not modified by this PRD — no derivation from the tree, no denormalisation, no coupling. It keeps its current meaning and uses (PYQ-required,YearsDBModel, year filters). The exam tree’sYEARlevel is a separate, independent axis; the two are not synced and may legitimately differ. The content team picks the year as part of the tree path and theyearfield continues to be set as it is today.
New entity — Exam node (ExamDBModel, collection exam; one document per
EXAM / YEAR / PHASE node; shape mirrors a taxonomy node)
| Field | Type | Notes |
|---|---|---|
name | string | Required. Node label — e.g. "NEET PG", "2026", "January Session". |
parent_id | id | Parent node (sentinel default for a root node, exactly how taxonomy marks L1). |
course_id | CourseEnum | Required. DMED (the existing Medical course) at launch. Scopes the tree per course, exactly like taxonomy. |
node_type | ExamNodeTypeEnum | Required. New. What the node is: EXAM = 1 | YEAR = 2 | PHASE = 3. Descriptive only — not position-bound: any type may sit at any level (trees vary in shape/depth by exam). Drives UI labelling and type-based filters (e.g. “sittings” = PHASE-type nodes). |
sort_order | int | Ordering within parent — same role as taxonomy sort_order. |
status | enum | DRAFT / PUBLISHED / UNPUBLISHED / ARCHIVE — same lifecycle as taxonomy nodes. |
mcq_count | object | Per-type counts (pyq/dq/eq, all + published) — ExamMCQCount, mirroring TaxonomyMCQCount. Maintained the same way taxonomy counts are: a background recompute task (mirroring bg_tasks/taxonomy/update_all_count_stats, via a count_by_* repo query) triggered on MCQ create/update/status-change — not an inline per-write increment. |
emoji, description, short_uid, internal_display_uid | — | Same conventions as taxonomy: per-course unique short_uid (prefix EXM) and display uid, optional emoji/description. |
Indexes mirror taxonomy’s set with exam_ names: parent_id; course_id;
(course_id, short_uid) unique; (course_id, internal_display_uid) unique-partial;
(parent_id, sort_order) partial on is_deleted: False; the updated_at
pagination compound.
MCQ changes (on McqDBModel — mirroring taxonomy_ids / root_taxonomy_id 1:1)
| Field | Type | Notes |
|---|---|---|
exam_nodes | array of ids | New. Exam node ids in hierarchical (root→end) order — 1 to 3 elements depending on how deep the exam’s tree goes (e.g. [EXAM], [EXAM, YEAR], [EXAM, YEAR, PHASE]). Filtered via a flat $in (exam_nodes__in), indexed multikey like taxonomy_ids. Empty for questions in courses with no exam tree and for the legacy pile until classified. |
root_exam_id | id | New. Denormalised reference to the path’s root (L1) node (always exam_nodes[0]) — same role as root_taxonomy_id. |
exam_nodes and root_exam_id are the only fields this PRD adds to
McqDBModel. The existing year field is not changed (see the “year is
untouched” decision above).
Course changes — none. The Medical course already exists as CourseEnum.DMED
(4, “Design Med”); this PRD adds no course value.
Classification Rules (cardinality & depth)
Exactly one path, ending at a leaf (decided — supersedes the earlier per-type depth rules): a Medical question belongs to exactly one exam path (one
exam_nodeslist of 1–3 nodes), and the path must descend as deep as the tree goes: if the chosen end node has child nodes, the question cannot stop there — one of the children must be selected. E.g. filing at “NEET → 2026” is invalid while 2026 has phases under it; a specific phase must be picked. The same rule applies at every level (an EXAM with years requires a year), and L1 is always required. A question’s path therefore always ends at a leaf of its exam’s tree.There is still no minimum-depth requirement keyed off
question_type— an exam conducted once and never subdivided is a 1-node tree, so its questions legitimately end at the EXAM node. Required depth is dictated by the tree’s actual shape, not by the question type.A question is therefore always attributable to one exam (
exam_nodes[0]/root_exam_id), and is pinned to a year or sitting exactly when its exam’s tree has one — single, unambiguous membership either way.
Trees are built first, then used (decided): the inverse of the leaf rule is also enforced — a node that has questions filed directly on it (paths ending there) cannot gain children. Adding a level under an in-use node is a workflow error and is blocked at node creation; the expected flow is to build the exam’s full shape up front, before questions are filed onto it. (This keeps the leaf rule from ever being violated retroactively — no stale paths.)
Filtering follows directly: “all questions for Exam A” matches the root node;
“all questions from the 2026 January sitting” matches that PHASE-type node — any
node in exam_nodes is matchable via the flat $in.
Year field (unchanged)
This PRD does not touch McqDBModel.year. It keeps its current behaviour
everywhere (mandatory for PYQ at insert, optional for DQ/EQ, feeding YearsDBModel
and year filters). It also does not touch the existing years collection
(YearsDBModel) — the per-(course, year) aggregate (id course_id_year, with its
own pyq/dq/eq counts and PublishingStatusEnum). That aggregate is a distinct
entity from the exam tree’s YEAR nodes and is left exactly as-is.
The exam tree’s YEAR level is a separate, independent axis; there is no
derivation or sync between it and either McqDBModel.year or YearsDBModel — by
design. A question can be filtered by sitting year through the tree’s YEAR node
and by year through the existing field, independently. The two are allowed to
differ.
Validation
| Rule | When |
|---|---|
Max depth 3 — a node’s parent chain may be at most 3 deep; nothing may be created below L3. No type↔position or parent-type rules — any node_type at any level (decided above). | Exam node create/update (service). |
Parent integrity — the parent (when not root) must exist, be live, and share the node’s course_id. | Exam node create/update. |
No children under in-use nodes — a node cannot be created (or moved) under a parent that has questions filed directly on it (i.e. MCQs whose exam_nodes path ends at that parent). The editor is told to re-file those questions first. | Exam node create/update. |
A Medical question has exactly one exam_nodes path of 1–3 nodes; the ids form a contiguous root→end parent chain in hierarchical order; every node is live and in the same course_id; root_exam_id == exam_nodes[0]. No per-question_type depth requirement. | Question create/update (the gate; see Rollout for when it turns on). |
Leaf completeness — the final node in exam_nodes must be a leaf (no live children). A path may not stop at a node that has children: if the EXAM has years, a year must be selected; if that year has phases, a phase must be selected. | Question create/update (same gate). |
Migration & Rollout
⚠️ Superseded as built. This section was the planned rollout; the shipped behaviour differs — enforcement is not staged (enforce-on-create for PYQs in any course with a tree, no flip step), and the legacy pile was backfilled by parsing each Medical PYQ’s
internal_display_uid. See Technical Details → Decisions & deviations from the PRD (as built).
Mandatory everywhere is the end state; rollout is phased (decided): exam and phase cannot be auto-derived for the legacy pile (that absence is the whole problem), so enforcement is staged to avoid gating release on a manual backfill.
- Ship the structure — the
examentity, tree management, question-classification UI, and theexam_nodes/root_exam_idfields — with the enforcement gate OFF. - Build the Medical tree — content team creates each exam’s tree (an
EXAMnode, withYEAR/PHASEnodes where that exam actually has them), plus a “show unclassified” admin filter to drive the backfill. - Enforce on new — every newly created or edited Medical question must carry a valid path. The legacy pile is exempt for now.
- Backfill — the content team works the unclassified pile down (exam + phase are
manual; year may be seeded from the question’s existing
yearas a starting hint). - Flip the gate — once the pile is clear, enable mandatory-everywhere: no Medical question can be saved/published without a valid path.
Delivery Milestones
| # | Milestone | Outcome | Status | Plan / PR |
|---|---|---|---|---|
| 1 | Exam entity | The exam tree (collection exam, typed levels) is persisted and managed via admin (course-agnostic; DMED + UPSC both use it), mirroring taxonomy. | ✅ done | keystone #768 · web #435 |
| 2 | Question classification | A PYQ can be filed onto one exam_nodes path (existing year field unchanged). | ✅ done | keystone #768 · web #435 |
| 3 | Filter by exam / year / sitting | Admin filters the bank by exam, year, and sitting (av1). Client (v1/v2) filtering deferred — MCQ responses expose exam_nodes/root_exam_id read-only. | ✅ done (admin); client deferred | keystone #768 · web #435 |
| 4 | Enforcement + backfill | Enforce-on-create for PYQs in any course with a tree (no staged flip), “show unclassified” filter, and shipped PYQ backfills (Medical + UPSC). | ✅ done | keystone #768 |
As built: the enforcement model and milestone scope shifted during the build — see Technical Details → Decisions & deviations below. The original rows are kept for traceability; outcomes describe what actually shipped.
Open Questions
- None blocking. All architectural forks resolved during PRD review (course mapping, dedicated exam tree vs taxonomy, typed nodes + flexible shape, link shape + naming, year left untouched, cardinality, leaf completeness, build-first tree workflow, rollout sequencing).
- Resolved at build: client filtering did not ship for v1/v2 — admin (av1)
filtering only; v1/v2 MCQ responses expose
exam_nodes/root_exam_idread-only.
Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Backfill stalls. Exam+phase are manual; the pile may not get classified, so the gate never flips. | Med | Med — Medical stays partly unorganised | ”Show unclassified” filter + enforce-on-new caps the backlog from growing; flip is per-course, not global. |
| Inconsistent tree conventions. With no type↔position rules (by design), similar exams may be structured differently (one nests a YEAR, another puts PHASE directly under EXAM), making filters/UX uneven. | Med | Med — uneven filtering/browsing experience | Max-depth-3 + parent-integrity validation bound the damage; node_type labels make each node’s role visible in admin; content-team conventions (not code) keep sibling exams consistent. |
| Tree extension blocked by in-use nodes. Because child-adds under directly-filed nodes are blocked (decided), legitimately restructuring an exam (e.g. an exam becomes multi-sitting in a later year) requires re-filing its questions before the tree can grow. | Low | Med — tree edits wait on a re-filing pass | The block error lists/links the affected questions; admin filter (“filed at this node”) makes the re-filing pass mechanical. No stale paths can ever exist, by construction. |
Technical Details (as built)
Shipped. Backend: keystone PR #768
(merged, branch feature/exam-link). Admin: keystone-web PR #435 (merged to dev).
- Design spec:
keystone/docs/superpowers/specs/2026-06-12-medical-exam-sitting-design.md - Plans:
keystone/docs/superpowers/plans/2026-06-12-medical-exam-sitting-backend.md,keystone-web/docs/superpowers/plans/2026-06-12-medical-exam-sitting-admin.md
One-line approach: a new exam collection cloned from the taxonomy stack
(typed EXAM/YEAR/PHASE nodes, max depth 3, publish lifecycle + per-node counts),
with MCQs gaining exam_nodes (1–3-id root→leaf path) + denormalised
root_exam_id; questions are filed onto one leaf-terminated path, and the bank
filters by exam/year/sitting in admin.
Surfaces covered:
- keystone:
exam_models/(ExamDBModel+ExamProjection),exam_repository.py,exam_services.py,bg_tasks/exam/(count task), av1 exam CRUD (POST /av1/exam,GET /av1/exam,PATCH /av1/exam/{id}). MCQ av1 gainsexam_nodeson create/update and theexam_nodes__in/root_exam_id/has_exam_nodes/exam_path_end_idfilters. v1 + v2 MCQ responses exposeexam_nodes/root_exam_id(read-only); no v1/v2 exam endpoints. 35 tests (src/tests/exam/— schemas/service/linkage/migration) pass. - keystone-web: Exam Tree management module (clone of the taxonomy manager,
+ node_type), anExamPathPicker(dynamic-depth, leaf-terminated) in the MCQ create/edit forms, and exam-node / “show unclassified” / filed-at-node filters on the MCQ grid.
Naming: PRD vs code
| PRD term | Code |
|---|---|
| Medical course | CourseEnum.DMED (4, “Design Med”) — existing |
| exam tree / node | ExamDBModel, collection exam, short_uid prefix EXM — src/models/exam_models/db_model.py |
| node type (EXAM/YEAR/PHASE) | node_type: ExamNodeTypeEnum (EXAM=1, YEAR=2, PHASE=3) in src/constants/enums.py — descriptive, not position-bound |
| exam path on question | McqDBModel.exam_nodes + root_exam_id (mirror of taxonomy_ids / root_taxonomy_id) |
| per-node counts | ExamMCQCount (mirror of TaxonomyMCQCount), recomputed by exam_task_update_all_count_stats (bg_tasks/exam/) |
| sitting | PHASE-type node (node_type == ExamNodeTypeEnum.PHASE), at whatever depth its exam’s tree puts it |
| layered stack | exam_repository.py / exam_services.py / api/av1/exam/routes.py / bg_tasks/exam/ (av1 only — no v1/v2 exam routes) |
Decisions & deviations from the PRD (as built)
- Enforcement is course-agnostic + PYQ-only, gated on tree existence — not the
staged, Medical-only, all-question-types gate the PRD described. As built
(
MCQService._ensure_exam_nodes_present_for_pyq→ExamService.course_has_tree): a question must carry a validexam_nodespath at create iff it is a PYQ and its course has a live exam tree. DQ/EQ are exempt; courses without a tree are unaffected; no course is special-cased (the plannedEXAM_CLASSIFICATION_REQUIRED_COURSE_IDSconstant was removed). Update validates only when a path is sent and refuses to clear a PYQ’s path. There is no feature flag and no “flip the gate” step — the product is not live, so enforcement is on from day one. This supersedes §Migration & Rollout steps 3 & 5 and narrows the PRD’s “classification is mandatory for Medical” to PYQs only. - The legacy pile WAS backfilled (the PRD assumed it could not be). Two
idempotent,
--dry-run-capable one-time migrations classify existing PYQs, preservingupdated_atand recomputing node counts; DQ/EQ are never touched:src/core/migrations/jun_15_26/backfill_medical_exam_tree.py(DMED / course 4) derives each PYQ’s exam → year → sitting by parsinginternal_display_uid(the printed question code: INICETI<yy>(N|M)…, NEET-PGN<yy>[S1|S2]…), not fromMcqDBModel.year— directly honouring the “yearis untouched / independent axis” decision (the tree’s YEAR level is sourced from the code, not the field). Unparseable / fallback uids are skipped and reported (findable viahas_exam_nodes=false).src/core/migrations/jun_12_26/backfill_upsc_exam_tree.py(UPSC / course 1) maps PYQs toPrelims → <year> → GS.
- Medical was the second consumer, not the first. The axis shipped genuinely course-agnostic and was proven on UPSC (course 1) before Medical (DMED), confirming the “generic, Medical is just the first user” intent more strongly than planned.
- Client filtering was not built (resolves the open question). Only admin (av1) gets exam filters; v1/v2 receive read-only field exposure on MCQ responses, no exam filter endpoints.
docket_countis not on exam nodes (taxonomy carries it; nothing files dockets onto the exam tree) —ExamMCQCountonly.- In-use-node guard uses a
$nincontiguous-path count (McqRepository.count_by_exam_path_end), not$expr, so the “node with paths ending on it can’t gain children” rule is fully unit-tested under mongomock.
Migration
- Indexes/collection created on boot via
MODELS_TO_MIGRATE(ExamDBModelregistered). Two one-time PYQ backfills shipped (Medical + UPSC, above); both idempotent, dry-runnable,updated_at-preserving, and re-run safe. Non-PYQ and unparseable/un-yeared questions remain unclassified by design and are surfaced via thehas_exam_nodes=falseadmin filter.