Owner Burhan

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:

  1. Existing Medical course (DMED) — the single course already exists as CourseEnum.DMED (4, “Design Med”); no new course is added. The two exams live inside it, not as separate courses.
  2. 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.
  3. 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.
  4. 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).
  5. Existing year field untouched — the MCQ year stays exactly as today (independent, hand-entered, mandatory for PYQ). The exam tree’s YEAR level is a separate axis; this PRD deliberately does not couple or sync them.
  6. 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 medical CourseEnum values (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 existing TaxonomyDBModel is left untouched (zero regression on taxonomy queries/counts). Genericity is carried by course_id (any course can grow an exam tree), not by a dimension discriminator — there is no dimension field; if a genuinely different classification axis ever appears, it gets its own collection, the same way exam mirrors taxonomy.

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 (keeping exam_nodes within the same 3-element shape as taxonomy_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 denormalised root_exam_id, mirroring taxonomy_ids / root_taxonomy_id 1:1 (flat $in filtering at any level, same index shape). Named exam_nodes (not exam_ids) because the list holds node ids across levels, not ids of exams. (Supersedes the earlier dimension-keyed structure_paths idea, which died with the dimension field.)

year is untouched (decided): the existing McqDBModel.year field 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’s YEAR level 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 the year field 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)

FieldTypeNotes
namestringRequired. Node label — e.g. "NEET PG", "2026", "January Session".
parent_ididParent node (sentinel default for a root node, exactly how taxonomy marks L1).
course_idCourseEnumRequired. DMED (the existing Medical course) at launch. Scopes the tree per course, exactly like taxonomy.
node_typeExamNodeTypeEnumRequired. 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_orderintOrdering within parent — same role as taxonomy sort_order.
statusenumDRAFT / PUBLISHED / UNPUBLISHED / ARCHIVE — same lifecycle as taxonomy nodes.
mcq_countobjectPer-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_uidSame 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)

FieldTypeNotes
exam_nodesarray of idsNew. 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_ididNew. 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 changesnone. 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_nodes list 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

RuleWhen
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.

  1. Ship the structure — the exam entity, tree management, question-classification UI, and the exam_nodes / root_exam_id fields — with the enforcement gate OFF.
  2. Build the Medical tree — content team creates each exam’s tree (an EXAM node, with YEAR / PHASE nodes where that exam actually has them), plus a “show unclassified” admin filter to drive the backfill.
  3. Enforce on new — every newly created or edited Medical question must carry a valid path. The legacy pile is exempt for now.
  4. Backfill — the content team works the unclassified pile down (exam + phase are manual; year may be seeded from the question’s existing year as a starting hint).
  5. Flip the gate — once the pile is clear, enable mandatory-everywhere: no Medical question can be saved/published without a valid path.

Delivery Milestones

#MilestoneOutcomeStatusPlan / PR
1Exam entityThe exam tree (collection exam, typed levels) is persisted and managed via admin (course-agnostic; DMED + UPSC both use it), mirroring taxonomy.✅ donekeystone #768 · web #435
2Question classificationA PYQ can be filed onto one exam_nodes path (existing year field unchanged).✅ donekeystone #768 · web #435
3Filter by exam / year / sittingAdmin 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 deferredkeystone #768 · web #435
4Enforcement + backfillEnforce-on-create for PYQs in any course with a tree (no staged flip), “show unclassified” filter, and shipped PYQ backfills (Medical + UPSC).✅ donekeystone #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_id read-only.

Risks

RiskLikelihoodImpactMitigation
Backfill stalls. Exam+phase are manual; the pile may not get classified, so the gate never flips.MedMed — 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.MedMed — uneven filtering/browsing experienceMax-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.LowMed — tree edits wait on a re-filing passThe 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 gains exam_nodes on create/update and the exam_nodes__in / root_exam_id / has_exam_nodes / exam_path_end_id filters. v1 + v2 MCQ responses expose exam_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), an ExamPathPicker (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 termCode
Medical courseCourseEnum.DMED (4, “Design Med”) — existing
exam tree / nodeExamDBModel, collection exam, short_uid prefix EXMsrc/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 questionMcqDBModel.exam_nodes + root_exam_id (mirror of taxonomy_ids / root_taxonomy_id)
per-node countsExamMCQCount (mirror of TaxonomyMCQCount), recomputed by exam_task_update_all_count_stats (bg_tasks/exam/)
sittingPHASE-type node (node_type == ExamNodeTypeEnum.PHASE), at whatever depth its exam’s tree puts it
layered stackexam_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_pyqExamService.course_has_tree): a question must carry a valid exam_nodes path 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 planned EXAM_CLASSIFICATION_REQUIRED_COURSE_IDS constant 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, preserving updated_at and 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 parsing internal_display_uid (the printed question code: INICET I<yy>(N|M)…, NEET-PG N<yy>[S1|S2]…), not from McqDBModel.year — directly honouring the “year is 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 via has_exam_nodes=false).
    • src/core/migrations/jun_12_26/backfill_upsc_exam_tree.py (UPSC / course 1) maps PYQs to Prelims → <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_count is not on exam nodes (taxonomy carries it; nothing files dockets onto the exam tree) — ExamMCQCount only.
  • In-use-node guard uses a $nin contiguous-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 (ExamDBModel registered). 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 the has_exam_nodes=false admin filter.