Practice Hub Engine: Design & Business Logic¶
Status: shipped (JEE + NEET live), roadmap open past M3 LNO: L. The ranking engine behind the Practice Hub. Defines where importance, mastery and coverage come from, and which of those sources are real today. Owners: @bhanu Last updated: 2026-07-13 Roles affected: B2C free, B2C Pro. B2B unaffected. Primary routes:
GET /api/v1/practice-hub,GET /api/v1/practice-hub/catalog,PATCH /api/v1/practice-hub/exam-targetSource of the ask: product direction, @bhanu. Refreshed 2026-07-13 against code after PRs #1277, #1305, #1314, #1317 changed the data path out from under the original draft. Related: Two Faces + Start-Practice Wizard, Practice Hub Freemium Gate, Practice Player, Study Plan (Vision)
TL;DR¶
priority = importance × gap, computed deterministically per request. Importance is recency-weighted past-paper yield, aggregated live from the exam bank in the knowledge_base_status database. Gap comes from the learner's own practice_attempts rows in the app DB. The app-side pyq_chapter_weightage and exam_questions copies are gone: migration 15c5ac44cf82 dropped both, and every read now goes through src/services/exam_bank/. JEE and NEET are both live. Everything else (school, subject-learner, corporate) still gets an honest, reason-tagged empty state.
The Practice Hub PM Guide explains the one rule a learner sees: study what is both heavily-tested and where you are weak. This doc is the engineering and business-logic counterpart: how that rule is computed, what data feeds it, and why the design is what it is.
Naming
This engine shipped as "Study Plan" and is now the Practice Hub: the live JEE/NEET past-paper-ranked practice surface. The name Study Plan now refers to a separate bet that generates plans and practice from NCERT or a learner's own material, see Study Plan (Vision). The two share this engine's seams (UnitProvider, MasteryProvider), which is exactly why the vision is mostly a matter of new plug-ins, not a new engine.
The promise scales across segments only if it rests on one engine, not a JEE-only special case. The engine ships with JEE live first and adds the rest as plug-ins, with no change to the engine, the API contract, or the UI.
Status
- M1 (kwiloai_webapp PR #1204, 2026-07-02): JEE engine live with the
pyq_chapter_weightageseed (82 chapters), an exam-target switcher, frontend wired. - M2 (2026-07-03): a mastery write-path into kwiloai-memory
learner_masteryshipped alongsideInteractionCounterMasteryProvider. - Rename (2026-07-03): the surface is "Practice Hub" everywhere user-facing.
exam_familyresolves only for exam-eligible learners, so non-exam learners never see JEE/NEET; they get a reason-tagged empty state. - Route move: the engine now serves
/api/v1/practice-hub(src/api/v1/practice_hub.py)./study-planis a different feature now: the syllabus planner (src/api/v1/study_plan.py). Any older reference toGET /api/v1/study-planfor the ranked map is stale. - Coverage-aware status (PR #1277): chapter
statusandaccuracyPctare derived frompractice_attempts.AttemptsDerivedMasteryProvider+AttemptsCoverageProvider(src/services/plan_engine/strategies.py:194,252) replacedInteractionCounterMasteryProvideras the wired mastery source, becauselearner_masterynever got populated in prod (see #1127). The counter provider is still in the file but not wired intoStudyPlanService.build()(strategies.py:139-142), andsrc/services/mastery_counters.pynow has exactly one importer:strategies.py. - Live bank reads (PRs #1314, #1317, 2026-07-10): the app-side ETL copies are dropped. Migration
apps/backend/alembic/versions/15c5ac44cf82_drop_exam_questions_and_pyq_chapter_.pycallsop.drop_table("exam_questions")andop.drop_table("pyq_chapter_weightage"). Weightage is aggregated per request from the live bank inknowledge_base_status(src/services/exam_bank/weightage.py). NEET is live, not parked:EXAM_TO_FAMILYmaps NEET / AIPMT / AIIMS / KARNATAKA NEET (src/services/exam_bank/normalize.py:19-27), andEXAM_FAMILIES = {"jee", "neet"}(src/services/plan_engine/goal_resolver.py:63).
Core principle: one deterministic function, two pluggable seams¶
The plan is a deterministic scoring function constrained by a knowledge graph, not an agentic (LLM) system. No LLM in the request path. Pure function: data in, ranked list out, same answer every time.
priority(unit) = importance(unit | goal) × gap(learner, unit)
importance is pluggable per stream. Everything else (gap, sequencing, phases, readiness, contract, UI) is universal and built once. Adding a segment means writing one strategy and registering it — open for extension, closed for modification.
Why deterministic, not ML: it is auditable (every rank is explainable to the learner who asks "why this chapter?"), fast (single-digit ms, zero cost per load), and reproducible/testable. This is also the SOTA-appropriate choice while interaction data is thin — feature-engineered logistic and simple Bayesian models match or beat deep knowledge tracing on sparse data, and an explicit knowledge structure (à la ALEKS knowledge-space theory) makes per-learner estimation sample-efficient. Deep KT, FSRS, and neural memory models are real upgrades, but they only earn their keep at large interaction volume, so they are deferred (see Roadmap).
Architecture¶
A Stream = (which curriculum units are in scope) + (how their importance is computed). Two seams realize that, plus universal machinery shared by all streams.
profile ─► GoalResolver ─► goal { stream, exam_family?, subjects[], target_date? }
│
┌─────────────────┴───────────────────────┐
▼ ▼
UnitProvider (seam: where units come from) ImportanceStrategy (seam: how to weight)
├ BankWeightageUnitProvider live exam bank ├ recency-weighted yield (live, JEE + NEET)
├ ConceptGraphUnits (planned) kwiloai_memory ├ DagDepthStrategy prereq depth (planned)
└ ContentUnits (planned) Course/Lesson └ CuratedOrderStrategy curated order (planned)
│ │
│ MasteryProvider (universal): gap = 1 − EB_shrink(mastery from practice_attempts)
│ CoverageProvider (universal): attempted / corpus total, drives `status`
│ │
└──────────────────► Ranker ◄──────────────┘
π = importance × gap, sorted by priority desc
│
PlanAssembler ─► frozen TStudyPlan (every time, never 503)
The diagram image is older than this page
../assets/practice-hub-engine.svg still labels the mastery source learner_mastery. That table is empty in prod and is no longer what the engine reads; mastery comes from practice_attempts. The ASCII block above is the current shape. Only BankWeightageUnitProvider is wired today (src/services/plan_engine/service.py:91); the ImportanceStrategy protocol still exists (seams.py:77) but no concrete strategy class is instantiated, because importance is read straight off each Unit.recency_pct (service.py:102). The other providers named above are design intent, not code.
| Component | Responsibility | Universal or pluggable |
|---|---|---|
GoalResolver |
profile signals → a goal |
universal |
UnitProvider |
which units are in scope, and where they come from | pluggable |
ImportanceStrategy |
importance I_c of each unit for the goal |
pluggable |
MasteryProvider |
learner mastery → gap g_c |
universal |
Ranker |
π = I × g, sequencing within prerequisite bands |
universal |
PlanAssembler |
readiness, phases, today, subjects → frozen contract | universal |
Goal resolution: from what we already ask, to a stream¶
Kwilo already collects structured signal in two intake flows. The resolver reads them — it never fuzzy-parses free text.
Signup 3-step (persisted on the User, webapp DB):
| Step | Field | Values |
|---|---|---|
| Persona | signup_intent |
learner · trainer · parent · creator |
| Mode | signup_mode |
lesson_plan · ppt · question_paper · research · exam_prep · notes |
| Topic | signup_topic |
free text (advisory only) |
Onboarding wizard 3-step (persisted in kwiloai-memory profiles.preferences['onboarding']):
| Step | Field | Values |
|---|---|---|
| Education stage | education_stage |
primary · secondary · pu · undergrad · postgrad · working_pro · exam_prep |
| Subjects | subjects[] |
structured, varies by stage |
| Goal | goal |
homework_help · understand_coursework · score_well · learn_something_new |
The resolver maps these to a stream:
| Signal | Stream | Units → importance | Today |
|---|---|---|---|
exam family resolves to jee |
jee | live exam bank → recency-weighted yield | live |
exam family resolves to neet |
neet | live exam bank → recency-weighted yield | live |
| primary / secondary / school / pu, no exam family | school | (planned) ConceptGraph → DagDepth | empty state, needs_exam_choice |
exam_prep stage, no exam family yet |
exam_prep | n/a until the learner picks | empty state, needs_exam_choice |
| undergrad / postgrad | subject_learner | (planned) ConceptGraph → DagDepth | empty state, no_exam_target |
| working_pro | corporate | (planned) CuratedOrder | empty state, no_exam_target |
| anything unmatched | generic | none | empty state, no_exam_target |
Streams and their reason codes are const-map dispatch: _STAGE_STREAM (goal_resolver.py:165), EXAM_ELIGIBLE_STREAMS = {"school", "exam_prep"} (goal_resolver.py:161), and the reason split in StudyPlanService.build(). Exam intent is orthogonal to education stage: when an exam family resolves, it takes over the stream, so a class 5-12 learner targeting NEET is on the neet path regardless of stage (goal_resolver.py:205). A learner with no onboarding block at all gets no_preferences.
JEE vs NEET: inferred from subjects, learner-overridable¶
Exam-prep intent tells us the learner is preparing for an exam, but not which exam, and that decides which slice of the bank to weight. resolve_exam_family (goal_resolver.py:95) takes the most explicit signal first:
exam_family =
1. user.exam_target_override (explicit learner choice)
2. onboarding_block["exam_family"] (captured at onboarding)
3. inferFromSubjects(onboarding subjects)
inferFromSubjects: (goal_resolver.py:75)
⊇ {physics, chemistry, mathematics} → jee
⊇ {physics, chemistry, biology} → neet
{physics, chemistry, mathematics, biology} → jee (the PCM superset check fires first)
otherwise → null (empty state, reason-tagged)
Each candidate is normalized and accepted only if it is in EXAM_FAMILIES = {"jee", "neet"}. exam_target_override is a nullable column on users. Null means "trust the inference"; a value wins over it. The learner sets it from Face A's JEE/NEET tiles, which call PATCH /api/v1/practice-hub/exam-target and get the rebuilt plan back in the same response. This keeps onboarding short while letting a NEET aspirant who listed PCMB correct the guess in one tap.
An unresolved exam family is not the same failure for everyone. School-band and exam-prep learners are exam-eligible, so they get needs_exam_choice, which is what renders the chooser. Subject learners and corporate learners get no_exam_target and are shown no exam options at all.
The "every learner gets a plan" guarantee¶
The resolution chain is ordered so it terminates at a provider that returns units: exam corpus where applicable, then the curriculum DAG, then the webapp's own Course / Lesson / LevelSubject content. The plan ranks on a real structural signal, never on invented numbers, and never returns 503 for a recognized learner.
Prod reality (code-verified 2026-07-13): the non-exam floor is still not seeded. The guarantee holds today for exam learners only (JEE and NEET), because the exam bank is the only populated unit source.
concepts/concept_edgesare still unseeded, soConceptGraphUnitsandDagDepthStrategydo not exist as code, only as named seams. Every non-exam stream gets an explicit, reason-tagged empty state (no_exam_target/needs_exam_choice/no_preferences), never a fabricated floor. A static taxonomy of chapter names was rejected: with no real importance data it would rank on invented numbers.
Mastery no longer comes from kwiloai-memory. The engine reads the learner's own practice_attempts rows in the app DB and computes accuracy per chapter in one grouped query (AttemptsDerivedMasteryProvider, strategies.py:194-249). This was a deliberate swap: learner_mastery was never populated in prod, while practice_attempts is written reliably by PracticeSessionService.submit_attempt. accuracyPct is null until the learner actually attempts questions in that chapter, and the ranking gap is not a flat 1 in the meantime. It is 1 − p_c, where p_c is the per-chapter difficulty prior computed from the bank's complexity mix (compute_chapter_prior, strategies.py:58; priors easy 0.65 / medium 0.50 / hard 0.35). So day-1 ranking already reflects real chapter difficulty. That is the documented cold-start state, not a bug.
Business logic: the scoring derivation¶
For each in-scope unit (chapter) c:
| Symbol | Meaning | Source in code |
|---|---|---|
I_c |
importance of c for the goal |
Unit.recency_pct / 100 from the live bank aggregation (service.py:102) |
m_c |
raw observed accuracy of c |
correct / attempts over the learner's practice_attempts (strategies.py:231-247) |
n_c |
attempts on c |
count of the learner's practice_attempts rows for that chapter |
p_c |
per-chapter difficulty prior | weighted from the bank complexity mix, never from the mastery row (ranker.py:102-104) |
k |
shrinkage strength (pseudo-count) | EB_K = 5 (ranker.py:17) |
-
Shrink mastery toward the chapter difficulty prior so a single lucky or unlucky attempt does not swing the rank (empirical Bayes):
m̂_c = (n_c · m_c + k · p_c) / (n_c + k)Zero attempts means
m_c = p_candn_c = 0, som̂_c = p_cexactly. DisplayedaccuracyPctisnull(the UI shows a dash), and the ranking gap is1 − p_c, not1. -
Gap:
g_c = 1 − m̂_c. -
Priority:
π_c = I_c × g_c, sorted descending (ranker.py:129). -
Sequence: prerequisite banding is a named no-op seam today (
_prereq_gate_seam,ranker.py:67-80). It returns the plain priority sort unchanged, and lights up only whenconcept_edgesis seeded.today= the top-priority chapter that is not named "Miscellaneous" (assembler.py:205-208), which ranks honestly in the list but makes a poor daily focus. -
Assemble:
readiness = Σ(I_c · m̂_c over units with n_c > 0) / Σ(I_c). Units with zero attempts contribute 0 to the numerator but keep their full weight in the denominator, so a cold-start learner reads 0.0 rather than an inflated prior-driven number (assembler.py:158-168).phases(diagnose → master → simulate) is a readiness-threshold state machine expressed as a const-map: diagnose < 20%, master 20-79%, simulate ≥ 80% (assembler.py:24-28).- per-chapter
statusis coverage-first, not accuracy-first:masteredrequires the learner to have attempted every question in the chapter (coverage 100%) and clear an 80% accuracy gate. Perfect accuracy on a handful of questions never marks a chapter done (_chapter_status,assembler.py:61-92). streakDaysis currently hardcoded0(assembler.py:232) anddueRevisionsis hardcoded0(assembler.py:224). The habit strip renders both. Neither is computed yet.
Importance strategies¶
| Stream | Importance source | Status |
|---|---|---|
| JEE | recency-weighted PYQ yield, 7-year half-life | live |
| NEET | same recipe, NEET family (NEET / AIPMT / AIIMS / KARNATAKA NEET raw values) |
live |
| school / subject (no exam) | DAG centrality / prerequisite depth | not built |
| CBSE / state board | board blueprint weights | not built |
| corporate | competency map / curated order | not built |
Both live streams run the same pure function, compute_weightage (src/services/exam_bank/weightage.py:45): each question's recency weight is 0.5 ** ((anchor − year) / 7.0), where anchor is the newest known year within that exam family, and a null year is treated as FALLBACK_YEAR = 1978 so it can never outweigh a recent question. Per-chapter weights are normalized within (subject, exam_family), which is why yieldPct values are comparable inside a subject tab and not across subjects.
What is NOT verifiable from code: which subjects the NEET side of the bank actually contains. The webapp does not own the bank; it reads whatever knowledge_base_status holds. A previous note in this doc claimed "NEET is Chemistry only". That was a statement about data, not code, and it is not something this repo can confirm or refute. The runtime source of truth is GET /api/v1/practice-hub/catalog, which returns one entry per (exam_family, subject) pair that has at least one chapter with pyq_count > 0, each carrying a hasBank flag. If a NEET subject is missing from the bank, the catalog simply omits it and the UI renders an honest empty tile. Do not restate a subject-coverage claim in this doc without querying the bank first.
The DAG never computes priority on the exam path; it is intended to provide sequencing (prerequisite bands) and eligibility. For no-exam streams it would additionally become the importance source. Neither is built.
A worked example¶
Illustrative numbers, not a snapshot of the live bank. Say JEE Physics weights Optics at ≈ 13% and Electrostatics at ≈ 10%. A learner who has ground through Optics (m̂ = 0.9, so g = 0.1) but never touched Electrostatics gets, for the untouched chapter, m̂ = p_c. Say a medium-difficulty mix puts p_c at 0.5, so g = 0.5.
π_Optics = 0.13 × 0.1 = 0.013 versus π_Electrostatics = 0.10 × 0.5 = 0.05.
Electrostatics wins despite lower raw importance, because the learner is weak there. Note the cold-start gap is 0.5, not 1: an untouched chapter is assumed to sit at its difficulty prior, not at zero knowledge. A hard untouched chapter (p_c = 0.35, g = 0.65) therefore outranks an easy untouched one of equal weight. That is the rule, computed.
Where the data lives¶
This table was rebuilt from code on 2026-07-13. The previous version was built on pyq_chapter_weightage, a table that no longer exists.
| Concern | Home today | Status |
|---|---|---|
| Exam weightage (JEE/NEET importance) | live exam bank in the knowledge_base_status DB: exam_chapters + exam_questions, mapped read-only in src/models/exam_bank.py on their own declarative base, reached through the second engine in src/db/kb_session.py. Aggregated per request by src/services/exam_bank/weightage.py |
shipped (PRs #1314, #1317) |
~~pyq_chapter_weightage~~ (app-side ETL copy) |
DROPPED. Migration 15c5ac44cf82_drop_exam_questions_and_pyq_chapter_.py drops both exam_questions and pyq_chapter_weightage from the app DB |
gone. Do not reference it |
Mastery (the gap term) |
app DB practice_attempts, grouped per chapter by AttemptsDerivedMasteryProvider (strategies.py:194). Written by PracticeSessionService.submit_attempt |
shipped |
Coverage (drives chapter status) |
two reads, never joined in SQL: distinct question_id count from app-DB practice_attempts, and the corpus total per chapter from the bank DB, combined in Python (AttemptsCoverageProvider, strategies.py:252-318) |
shipped |
kwiloai-memory learner_mastery |
still mapped (src/services/mastery_counters.py) and still read by InteractionCounterMasteryProvider, but that provider is not wired into the engine. Empty in prod (#1127) |
dormant, not on the request path |
| Curriculum DAG (sequencing + no-exam importance) | kwiloai-memory concepts / concept_edges |
schema exists, unseeded. _prereq_gate_seam is a no-op |
| Content floor (units of last resort) | webapp Course / Lesson / LevelSubject |
B2B org tables; a B2C workspace never populates them. Not a usable floor |
Chapter difficulty prior p_c |
derived from the bank's per-chapter complexity mix at request time (compute_chapter_prior) |
shipped |
| Onboarding signals (stream, subjects, class) | kwiloai-memory profiles.preferences['onboarding'], loaded via load_onboarding_block |
shipped |
| Exam target override | app DB users.exam_target_override, written by PATCH /api/v1/practice-hub/exam-target |
shipped |
| Read endpoints | GET /api/v1/practice-hub, GET /api/v1/practice-hub/catalog (src/api/v1/practice_hub.py) |
shipped |
| Entitlement (Pro gate) | webapp B2C plan state; enforced at the session, not the hub, by PracticeSessionService._enforce_free_tier_gate |
shipped |
Cost of the swap. The old design deliberately kept the question corpus out of the request path by pre-aggregating it into an ~82-row app-side table. That table is gone, so the aggregation now runs per request against the bank: fetch_weightage_rows pulls one row per bank question for the family and compute_weightage folds them into per-chapter rows in Python. compute_weightage is a pure function precisely so the recency math is unit-testable without a DB, and the bank engine is a small dedicated pool (_POOL_SIZE = 2, kb_session.py:34). No caching layer sits in front of it today. If the hub gets slow, this is the first place to look, and the honest answer is that nothing in the repo currently measures it.
The bank is a foreign schema: the webapp never writes it, and the models sit on a separate declarative base so Alembic autogenerate stays blind to them. When the bank is unreachable, ExamBankUnavailableError is caught inside the provider (strategies.py:109), which returns an empty unit list; the service turns that into a corpus_pending empty-state plan rather than a 5xx. Coverage totals degrade the same way, defaulting to 0 (strategies.py:308).
API contract¶
Three routes, all under /api/v1/practice-hub, all with a router-level require_roles(b2c_user) dependency (src/api/v1/practice_hub.py:35-39). All three are open to free and Pro learners alike: the hub listing is browsable, and the payoff gate lives on Practice Sessions.
| Route | Purpose |
|---|---|
GET /api/v1/practice-hub |
the ranked plan for the current learner (TStudyPlan) |
GET /api/v1/practice-hub/catalog |
exam-family-agnostic bank catalog: every (exam_family, subject) with bank chapters, regardless of the caller's exam target. Backs the start-practice wizard |
PATCH /api/v1/practice-hub/exam-target |
writes exam_target_override (jee, neet, or null to clear) and returns the rebuilt plan |
/study-plan is a different feature
GET /api/v1/study-plan no longer serves the ranked map. That path is now the syllabus planner (src/api/v1/study_plan.py). Older docs pointing at /study-plan for the Practice Hub are stale.
| Case | Response |
|---|---|
| b2c learner (free or Pro), exam family resolves, bank has rows | 200 valid TStudyPlan |
| New learner, no attempts | 200 with accuracyPct: null everywhere, readinessPct: 0.0 |
Wrong role (not b2c_user) |
403 |
| No / invalid token | 401 |
Invalid exam_target on the PATCH |
400 |
| No onboarding block at all | 200 empty-state, reason: no_preferences |
| Exam-eligible learner who has not picked JEE vs NEET | 200 empty-state, reason: needs_exam_choice (drives Face A's exam tiles) |
| Non-exam stream (subject learner, corporate, generic) | 200 empty-state, reason: no_exam_target |
| Exam family set but the bank returns no rows for it | 200 empty-state, reason: corpus_pending |
| Free learner practising a non-rank-1 chapter | 402 upgrade-required (raised by the session route, not the hub) |
The four reason codes are the EmptyPlanReason StrEnum (src/schemas/practice_hub.py:14-23) and the frontend Zod enum expects exactly these strings. An empty state is always a 200 with subjects: [] and today: null, never a fake plan and never a 503. accuracyPct, coveragePct and today are nullable and are null, not absent.
Roadmap¶
| Milestone | Deliverable | Status |
|---|---|---|
| M1 | JEE engine + seams, difficulty-prior cold-start, non-JEE empty-state | shipped |
| M2 | real mastery feeding the rank, so accuracyPct stops being null once a learner practises |
shipped, but not the way this doc originally planned: it landed as practice_attempts-derived mastery (PR #1277), not the kwiloai-memory learner_mastery write-path. The memory path is dormant |
| M2.5 | live bank reads; pyq_chapter_weightage + exam_questions app-side copies dropped; NEET wired; coverage-gated chapter status; figures in the player |
shipped (PRs #1314, #1317, migration 15c5ac44cf82) |
| M3 (next) | NCERT → concepts / concept_edges DAG seeder → prerequisite banding and a DagDepthStrategy, which is what makes the "every learner gets a plan" guarantee true for non-exam streams |
not started. Gating need: NCERT taxonomy |
| M4 | CBSE / state-board blueprints, corporate competency | not started. Gating need: blueprint data |
| M5 | FSRS-7 + LKT, optional Elo online item-difficulty; a real streakDays and dueRevisions instead of the hardcoded zeros; grounded explanation + adaptive practice |
not started. Gating need: real review volume |
NEET is no longer a roadmap item. It shipped in PR #1314. The old roadmap parked it at M4 behind a "corpus permitting" caveat; that caveat is resolved in code, and whether a given NEET subject has bank rows is now answered at runtime by the catalog's hasBank flag rather than by a milestone.
The highest-leverage remaining build is M3, the DAG seeder: it is the only thing standing between the engine and non-exam learners, who today see nothing but an empty state. Deep models and FSRS only earn their keep at real interaction volume (Best-LR vs deep KT on sparse data; Gervet et al. JEDM 2020), so they stay parked at M5.
Non-goals¶
- An LLM in the request path. The rank is a pure function. It has to be explainable to a learner who asks "why this chapter?".
- A fabricated content floor. A stream with no real importance signal gets an empty state with a reason code, not a plan ranked on invented numbers.
- Owning the exam bank. The webapp reads
knowledge_base_statusand never writes it. Bank coverage gaps are fixed upstream in the ingestion pipeline, not here. - FSRS scheduling.
dueRevisionsreturns a hardcoded 0. - Streaks.
streakDaysreturns a hardcoded 0 despite the habit strip rendering it. - Fuzzy-mapping legacy free-string topics to chapters. Start clean with honest nulls.
- A learner-set exam date. It defaults to a season date per family and cohort year (
resolve_exam_date).
Rejected alternatives¶
- Keep the app-side
pyq_chapter_weightageETL copy. Rejected and reverted. The code's own account: the table "was populated by a one-off seed + broken ETL" (weightage.py:3-4). Normalization now runs per request rather than in a batch, so a bank row that fails to normalize is skipped from this response instead of being silently absent from a stale copy (normalize.py:3-7). The cost, a per-request aggregation, is documented above and accepted. - A static NCERT chapter list as the non-exam floor. Rejected: without real importance data it would rank on invented numbers, which is exactly the failure mode the whole design exists to avoid. An honest empty state beats a plausible lie.
- Rank on
learner_masteryand wait for the memory ETL. Rejected after #1127: the table was empty in prod and nothing was filling it, whilepractice_attemptswas already being written on every graded answer. Ranking on the data we actually have beat waiting for the data we designed for. - Gate
masteredon accuracy alone. Rejected: a learner going 5-for-5 on a 60-question chapter is not done with it. Status is coverage-first, with accuracy as a second gate.
Related¶
- Two Faces + Start-Practice Wizard — the surface this engine feeds
- Practice Hub PM Guide — the one rule, for a non-engineer
- Practice Player — what a learner sees after tapping a chapter
- Study Plan (Vision) — the next bet: reuse these seams to generate plans and practice from NCERT or uploaded material
- Personalized Learner Journey — the served
LearnerProfilethis engine consumes - Practice Hub Freemium Gate — the Pro lock around the surface
- B2C flow — where the Practice Hub sits in the paid journey