1. Problem
Sunbird's unit of learning is a single course — enrol, consume, complete. It has no notion
of a structured program: an ordered journey of stages, each bundling several courses and
assessments, with completion and certification tracked across the whole thing.
Programs like capacity-building curricula need exactly that — a Learning Path:
Structure — ordered Levels, each containing Courses and Practice Question Sets.
One tracked journey — progress, completion, and certificate across the entire path, not
just per course.Adaptivity — a learner who can already demonstrate a skill should skip (waive) the
courses that teach it, decided by a diagnostic taken at entry or by prior learning.
Nothing in the platform models this today, and bolting on a parallel program-tracking stack
would duplicate the course machinery. This design adds Learning Paths by reusing that
machinery — the same batch, enrolment, consumption, assessment, and certificate flows — with a
thin layer on top.
2. Object Model
Prerequisites
A Framework — a multi-stage competency taxonomy — exists. Its leaf term is a Competency;
those Competencies are tagged (the competencies field) on every Content, Course, Question, and
Practice Question Set. An untagged object is invisible to waiver and course search — enforce at publish.
Example Framework:
Domain → Competency Area → Sub-Competency → Competency (Competency = leaf = the tag value) Teaching └─ Assessment Literacy ├─ Formative Assessment │ ├─ C1 "Frame effective in-class questions" │ └─ C2 "Run peer-assessment activities" └─ Summative Assessment └─ C3 "Assemble a balanced question paper"
Hierarchy
Learning Path → Level → (Course | Practice Question Set) → Course Unit → Content. Every
activity sits inside a Level — including assessments. Every Practice Question Set is wrapped
in its own Level, so a path's direct children are all Levels.
The activities — enrollable, batched, one user_enrolments row each — are Learning Path,
Course, and Practice Question Set. Level is a structural grouping (no batch, no row).
Course Unit and Content live inside a Course. Every Course, Question, Practice Question
Set, and Content carries its competencies — a Practice Question Set carries the set-level
competencies and each Question carries the competency(ies) it tests (per question, needed for
waiver §6).
Role is derived from Level index — no role field:
Position | Role | Behaviour |
|---|---|---|
PQS in Level at min index | pre / diagnostic | must finish first; drives waiver; counted like any activity |
PQS in Level at max index | post / summative | must pass; counted; gates path completion |
PQS in a middle Level | Level assessment | must pass; counted toward its Level |
Course in any Level | course | counted; waivable |
strategy — where waiver comes from:
Value | Waiver |
|---|---|
Fixed | none — every course mandatory; an entrance exam, if added, is just completed (no waiver) |
Diagnostic | from the pre-assessment PQS (Level[min]) taken at entry |
PriorLearning | learner's prior history — plus the pre-assessment result if an entrance exam is added (union) |
Authoring a Learning Path
Select the pre-assessment Practice Question Set (Level[min]). Its Competencies define the
LP's scope. In Diagnostic mode it drives waiver; a post-assessment PQS is
added as Level[max].Per Level: the author picks a subset of the scope Competencies → a content-search by
competency tag lists Courses tagged to them → selects the Courses (and any Level-assessment
PQS). Runtime waiver keys off each course's own competency tags.
3. Batching
Creating a Learning-Path batch fans out a batch per Course and per Practice Question Set (any depth); Levels get none. All batches live in activity_batch; the path batch is
activity_type = "Learning Path". Child batches inherit the path batch's dates. Multiple cohorts
per path are supported.
Batch id = composite {lpBatchId}:{childId} — recover the cohort from a child (split) and
address a child from the cohort (concatenate); no mapping table, idempotent fan-out. Assumes a
course/assessment appears once per path.
4. Data Model
activity_batch, user_enrolments, and user_content_consumption change — all just
generalise course → activity (mostly renames). assessment_aggregator and user_activity_agg are
unchanged.
Table | Rename | Add | Drop (verified dead) | Drop (candidate — confirm) |
|---|---|---|---|---|
activity_batch | courseid→activity_id | activity_type | — | createddate, startdate, enddate, enrollmentenddate, updateddate (text twins) |
user_enrolments | courseid→activity_id; contentstatus→activitystatus | activity_type, optional_activity | certificates, certstatus (superseded by issued_certificates) | enrolleddate (text twin of enrolled_date) |
user_content_consumption | courseid→activity_id | — | — | — |
user_content_consumption is role-unchanged (per-leaf consumption) — only its courseid PK
column is renamed for consistency, because it now holds Course ids and PQS ids: a Practice
Question Set consumes as (activity_id=qsId, contentid=qsId) — a collection that is its own single
content (leafNodesCount=1). The LP never writes here (§7); only Courses (per Content) and PQSs
(self-leaf) do.
Notes: keyspace sunbird_courses → sunbird_activity (one-time rename over all its tables);
course_batch → activity_batch. courseid is a PK column → in-place ALTER … RENAME;
activity_type backfilled "Course". datetime kept (verified live). progress (count) and
completionpercentage (%) both kept. Index user_enrolments_by_course →
user_enrolments_by_activity. Renames touch existing course code — one migration while the
install base is small.
CREATE TABLE sunbird_activity.activity_batch ( activity_id text, activity_type text, batchid text, name text, description text, status int, start_date timestamp, end_date timestamp, enrollment_enddate timestamp, enrollmenttype text, cert_templates map<text, frozen<map<text,text>>>, createdby text, createdfor list<text>, mentors list<text>, tandc boolean, created_date timestamp, updated_date timestamp, PRIMARY KEY (activity_id, batchid) ); CREATE TABLE sunbird_activity.user_enrolments ( userid text, activity_id text, activity_type text, batchid text, active boolean, addedby text, progress int, completionpercentage int, status int, activitystatus map<text,int>, -- child id → 0/1/2 (grain per row type, §5) optional_activity list<text>, -- waived course ids (path row only) issued_certificates list<frozen<map<text,text>>>, completedon timestamp, enrolled_date timestamp, datetime timestamp, lastaccesstime timestamp, lastreadactivityid text, lastreadactivitystatus int, PRIMARY KEY (userid, activity_id, batchid) );
5. Progress & Progression
5.1 Enrolment — sequential, backend-driven
Enrolment is backend-driven and sequential — the UI never calls enrol. Only one required
activity is open at a time; optional (waived) courses are all opened up front.
Enrol into the LP → LP row.
Set optional_activity by strategy: Fixed → empty; Diagnostic → from the
pre-assessment PQS (enrolled + completed first, then waiver runs); PriorLearning → from
history at enrol, and — if an entrance exam (pre-PQS) is present — unioned with its
diagnostic result after it completes.Enrol all optional (waived) courses at once — each gets its own user_enrolments row
(available to take, counted done via optional_activity, never gate).Enrol the first required activity (path order — Levels give the order + role).
On each required activity completing, the LP aggregator enrols the next required one.
Repeat → last required activity (post, if present) → path complete.
Lifecycle: Diagnostic starts with the pre-assessment gate → waiver; Fixed / PriorLearning go
straight to waiver (history or empty). After waiver, all optional courses open at once and required
activities open one-by-one, each on the previous one's completion → last required (post, if
present) → complete + certificate.
5.2 Progress rollup — two owners, bottom-up
Content → Course — the existing activity aggregator maintains each Course's
user_enrolments row (activitystatus of its Content leaves, progress, status=2) from
user_content_consumption. The LP layer does not touch this path.child activity → Learning Path — the new LP aggregator (§7) reacts to a Course or a
Practice Question Set completing and writes only the LP's own user_enrolments record.
A PQS self-completes on submit: a published Practice Question Set has leafNodesCount = 1 (itself), so the existing aggregator flips its row to status=2 on the assessment event — no publish change needed.
activitystatus grain by row: path = {childActivityId → status} (Courses + Level
assessments + post); Course = {contentId → status}; Practice Question Set = its single
leaf (self).
Percent (path) = |childrenDone ∪ optional| / N, where N = the LP's child-activity count
(every Course + every PQS — pre, Level-assessments, and post — each a leaf of the LP) and
childrenDone = children with status=2. A Course is done when its Content is consumed or it
is waived; an assessment is done when passed (pre is done on attempt — no cutoff).
Worked example — 10 child activities: 3 waived by the diagnostic, 7 required. Every waived
child counts immediately (free credit); each required child counts once done:
Point in journey | waived | required done | numerator | percent |
|---|---|---|---|---|
right after pre-assessment (waiver frozen) | 3 | 0 | 3 | 30% |
finished 2 required activities | 3 | 2 | 5 | 50% |
all required done | 3 | 7 | 10 | 100% |
So the bar starts at 30% the moment the diagnostic waives 3 courses, then climbs as required
activities complete — waived work never has to be done, but still counts toward 100%.
Pass-gate: a PQS row flips to status=2 on submit = attempted/consumed. The path counts
it done only if best total_score ≥ cutoff (learningpath.assessment.passThreshold) — so the
LP record's activitystatus[qsId] = 2 iff passed, else 1.
Completion: a Level completes when all its child activities are done-or-optional. The
Learning Path completes when all child activities are done (which, since pre and post are
counted, already requires the pre attempted and the post passed) → certificate.
Levels are derived — computed at read time, never stored. The read API / UI groups the LP
record's activitystatus by Level via the hierarchy. (Per-Level analytics, if ever needed, reuse
user_activity_agg with activity_id = levelId — no new table.)
6. Waiver
A course is waivable when all its Competencies are proven:
course.competencies ⊆ provenCompetencies.
Diagnostic — provenCompetencies = Competencies of questions answered correctly
on the pre-assessment: read assessment_aggregator.question for entries with score > 0,
resolve each questionId → Competency from question metadata (via {qsId}:competencies, §7.1).PriorLearning — provenCompetencies = learner's history (past passed questions + completed
courses' tags), unioned with the pre-assessment's correct-answer Competencies if an
entrance exam is present. No entrance exam → history only.Fixed — proven set empty; an entrance exam, if present, is completed like any PQS but
drives no waiver.
Computed once (at pre-assessment submit / enrolment), stored on the path row's
optional_activity and marked done in activitystatus; never re-derived. Waived courses are
enrolled (own row, §5.1) but never gate.
7. Runtime — the LP aggregator
Mirrors the existing ActivityAggregatorActor (verified): inline actor, one operation, two modes
(incremental / force-sync). Uniform trigger: every child activity — Course or Practice
Question Set — that completes emits enrol-complete; the LP aggregator is notified
(userId, batchid, activityId) via one in-process tell (an inline hook off completion; lern-service has no existing consumer to reuse). It self-filters: from the completed child it resolves the
parent LP via the existing ancestors cache (or trims the composite batchid to its base and
looks it up in activity_batch for an activity_type = "Learning Path" row); otherwise return
(standalone course).
It reads the LP's ordered child-activity list from the precomputed {lpId}:plan (built
once at publish from content_hierarchy — see §7.1; falls back to a content_hierarchy read on
cache miss). It does not use hierarchy_relations leaf nodes for this: those are the deep
Content of each Course, not the LP's activities, and carry no Level/order. It writes only the
LP's user_enrolments record; never reads Content-level status, never touches
user_content_consumption.
onChildComplete(userId, batchid, activityId): lp = parent LP via ancestors(activityId) (or activity_batch base lookup) # NULL ⇒ standalone → return lpId = lp.activity_id path = user_enrolments(userId, lpId, lpBatchId) # the LP record — the ONLY thing written role = roleOf(activityId, lpId) # from {lpId}:plan (Level index / type) if role == pre (diagnostic): path.activitystatus[activityId] = 2 path.optional_activity = runWaiver(userId, lpId) # §6 else if activityId is a Practice Question Set: # Level assessment or post pass = bestScore(assessment_aggregator, userId, activityId) >= cutoff path.activitystatus[activityId] = pass ? 2 : 1 else: # course path.activitystatus[activityId] = 2 N = child-activity count for lpId (all courses + all PQSs, from {lpId}:plan) done = { c : path.activitystatus[c] == 2 } path.completionpercentage = |done ∪ optional_activity| / N next = nextRequiredActivity(lpId, activityId) # next in {lpId}:plan not in optional_activity if next: autoEnrol(userId, next) # sequential — one required activity at a time (§5.1) if all done: path.status = 2; completedon; certificate + enrol-complete(lp) upsert path # idempotent; no user_content_consumption write
INCREMENTAL PATCH /v1/content/state/update → ContentConsumptionActor writes user_content_consumption / assessment consumption → existing ActivityAggregatorActor → COURSE or PQS row; status=2 on completion → notify LPAggregator(userId, batchid, activityId) [self-filters; writes only the LP record] FORCE-SYNC POST /v1/activity/agg (LP branch) (no per-activity payload) → read the LP's child rows (user_enrolments) + {lpId}:plan → rebuild the LP record (deep rebuild: run POST /v1/activity/agg per child first)
Recovery: all writes are idempotent upserts; every derived row is rebuildable — the LP record
from its child rows + {lpId}:plan, each course/PQS row from consumption/assessment.
Resync runs for one learner or a whole cohort.
7.1 Precomputed at publish (static per LP version)
The LP structure is identical for every learner and unchanged until republish, so two lookups are
built once at publish — via the existing relation-cache step (RelationCacheUpdater, which
already emits {id}:leafnodes/:ancestors) — and rebuilt on republish:
{lpId}:plan — the ordered activity list [{activityId, levelIndex, index, role, competencies}]
(role derived by Level index). Runtime = O(1) read; no per-event tree walk.{qsId}:competencies — question→Competency map for each Practice Question Set, so waiver (§6) doesn't fetch/parse question metadata at runtime.
Both fall back to a content_hierarchy / question read on cache miss.
8. APIs
No enrol change needed (verified): the enrol path (CourseEnrolmentActor.enroll) gates only on
batch existence + enrollmentType + dates — no mimeType/primaryCategory check — so a Learning
Path and a Practice Question Set are enrollable as-is; the only requirement is that an
activity_batch row exists. (The content-collection mimeType filter is on the enrolment-list
path, not enrol-write.)
Reused: POST /v1/activity/batch/create (create + fan-out per Course and PQS),
POST /v1/activity/enroll (called by the backend, sequentially — see §5.1),
PATCH /v1/content/state/update (content consumption and assessment submit — the latter with
courseId = qsId, batchId = {lp}:{qsId}), GET /v1/content/hierarchy/read, enrolment-list.
Recompute (reused — no new endpoint): POST /v1/activity/agg, dispatched by activity_type:
Incremental — pass activities: [{activityId, status}]: apply to the LP record's
activitystatus, recompute. (The completion tell is a one-item incremental.)Force-Sync — omit activities: rebuild the whole record from child rows + {lpId}:plan.
POST /v1/activity/agg { "request": { "userId": "<userId>", "activityId": "<lpId>", "activityType": "Learning Path", "batchId": "<lpBatchId>", "activities": [ { "activityId": "<childId>", "status": 2 } ] }}
Omit userId → whole cohort. Deep rebuild → first call activityType:"Course"/"Practice Question Set" per child, then the LP call.
Read (1 new): POST /v1/activity/state/read {userId, activityId=lpId, batchId} — returns the
LP record (activitystatus, progress, status, optional_activity) plus the current + next
required activity; the UI groups by Level via {lpId}:plan.
9. Decisions & Open Item
Decided: every PQS wrapped in its own Level; role derived from Level index (min=pre,
max=post, middle=Level assessment); assessments are enrollable activities (verified: enrol is
ungated — no core change); enrolment is sequential (backend-driven, one required activity at a
time; optional + required both enrolled); waiver = all Competencies, computed once at
pre-assessment submit (stored in optional_activity); pass threshold from config; batch id =
composite {lpBatchId}:{childId}; no publish change (PQS already leafNodesCount = 1 →
self-completes).
Open:
Missed-rollup detection — the aggregator is a fire-and-forget tell; if it fails mid-rollup
a path row goes stale until resynced. Options: (a) resync + read path; (b) periodic drift sweep;
(c) log+alert on hook failure. Recommendation: (c).Standalone-QS leaf-node cache — confirm the relation cache populates a Practice Question
Set's own leaf-node entry (leafNodesCount = 1) when enrolled independently, so the existing
aggregator self-completes it. Impl check, not a schema change.
10. Change Summary
New tables: 0. New keyspaces: 1 (sunbird_activity, via rename). New APIs: 1 (LP
state read); recompute reuses activity/agg. New runtime: 1 inline actor (LP aggregator).No enrol change (verified): enrol is ungated — LP + PQS enrollable as-is, only a batch row
required. (Earlier "CC1 mimeType filter change" was wrong — that filter is on the enrol-list path.)Schema: rename courseid→activity_id (in activity_batch, user_enrolments, and
user_content_consumption), contentstatus→activitystatus; add activity_type (batch +
enrolments) + optional_activity; drop certificates,certstatus (dead); text-twins
drop-candidates; index renamed.No publish change (PQS already 1 leaf — self-completes).
New logic: LP aggregator (rollup + waiver + pass-gate + enrol-next-required, hooked inline off
completion); question→Competency resolution; {lpId}:plan + {qsId}:competencies precompute at publish.Reused free: publish, content-consume, ActivityAggregatorActor (completes Course and
PQS rows), assessment_aggregator scoring, certificate, ancestors, /activity/enroll (called by backend).Deferred: dedicated attainment store (user_competency) — waivers from live data; per-Level
analytics via user_activity_agg if ever needed.
v11.1